40 Commits

Author SHA1 Message Date
curry772 e208a2d64b feat: CryptoFilter 프로퍼티 키 규격화 및 HSM 통합 테스트 추가
- CryptoFilter prop 키를 UPPER_UNDERSCORE 형식으로 변경 (CRYPTO_MODULE_NAME 등)
- CRYPTO_DEC_ENABLED / CRYPTO_ENC_ENABLED 옵션 제거 (필터 설정 시 항상 암복호화)
- getProp() 빈 문자열도 기본값으로 처리 (StringUtils.isBlank 적용)
- CryptoFilterHsmIntegrationTest 추가: SoftHSM2 연동 전체 흐름 검증 (7개 케이스)
  - attributes(*, CKO_SECRET_KEY, CKK_AES): 임포트 키도 CKA_SENSITIVE=false 적용
  - HsmKeyDerivationStrategy / HsmContextSha256KeyDerivationStrategy 검증
- HsmKeyRegisterUtil 추가: SoftHSM2 마스터키 등록 유틸리티
- CryptoModuleServiceTest: GCM AAD 관련 테스트 케이스 보강

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:29:02 +09:00
curry772 444b7ad595 feat: CryptoFilter GCM AAD 지원 추가 및 단위테스트 작성
- CryptoFilter: crypto.aad.header 프로퍼티 + buildAad() 메서드 추가
- CryptoFilter: 기본 scope SCOPE_BODY → SCOPE_FIELD 변경
- CryptoFilter: decryptBody/decryptField/encryptBody/encryptField aad 파라미터 적용
- BaseCryptoFilterTest: BODY/FIELD scope, buildAad, AAD 헤더 연동 단위테스트 신규 작성
- CryptoModuleServiceTest: STATIC/DYNAMIC GCM AAD 테스트 섹션 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:15:40 +09:00
curry772 085dd024d8 feat: HsmContextSha256KeyDerivationStrategyTest common 패키지로 이동
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:37:15 +09:00
curry772 3b6b3f6e4c Merge branch 'master' into feature/crypto-module 2026-05-12 16:33:59 +09:00
curry772 f4c28e8a27 feat: HSM 키 파생 전략 클래스 추가 (common 패키지로 이동)
- BaseHsmKeyDerivationStrategy: HSM 마스터키 조회 공통 로직 추상 기반 클래스
- HsmKeyDerivationStrategy: HSM 마스터키 직접 사용 전략
- HsmSha256KeyDerivationStrategy: HSM 마스터키 SHA-256 해싱 도출 전략
- HsmContextSha256KeyDerivationStrategy: HSM 마스터키 + 컨텍스트 SHA-256 해싱 도출 전략

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:30:26 +09:00
curry772 3cda2873cc feat: HTTP 어댑터 암복호화 필터 기반 클래스 추가 및 기본 scope 수정
- BaseCryptoFilter: 빈 RuntimeContext를 반환하는 기반 클래스 추가
- CryptoFilter: 기본 scope SCOPE_BODY → SCOPE_FIELD로 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:27:21 +09:00
curry772 c0efdd89d4 Merge branch 'feature/template-adapter-error-msg-handler' 2026-05-12 13:06:08 +09:00
curry772 3eac4eeaa6 feat: 모든 변수 표현식에 :기본값 문법 추가 및 JSON 필드 파싱 지원
- ${callprop.KEY:default} / ${callprop[path]:default} / ${MSG.path:default} 기본값 문법
- ${DATA.BIZDATA.acctNo} : StandardMessage String 값이 JSON이면 Gson으로 필드 추출
  (중첩 경로 지원, 없는 필드는 빈 문자열 또는 기본값)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:05:33 +09:00
curry772 c0c3f45d18 feat: 모든 변수 표현식에 :기본값 문법 추가
- ${callprop.KEY:default} - 직접 참조 기본값
- ${callprop[MSG.path]:default} - 간접 참조 기본값
- ${MSG.path:default} - 표준전문 경로 기본값
키/경로 미존재 또는 callProp null 시 기본값 반환, 기본값 미지정 시 빈 문자열.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:21:58 +09:00
curry772 2686448791 fix: GenerateMethod 테스트에서 AdapterPropManager → PropManager 목 객체로 변경
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:47:23 +09:00
curry772 3d4e5762c8 Merge branch 'feature/template-adapter-error-msg-handler' into master 2026-05-12 09:44:10 +09:00
curry772 f7859a77dc feat: 템플릿 기반 어댑터 에러 메시지 핸들러 추가
AdapterPropManager "AdapterErrorMessageHandler" 그룹의
{adapterGroupId}.template 키로 등록된 템플릿을 읽어
StandardMessage 및 callProp 값으로 치환한 에러 응답을 생성한다.

변수 치환 규칙:
- ${callprop.키}         → callProp.getProperty("키")
- ${MSG.MAIN_MSG.경로}  → StandardMessage.findItemValue("경로")
- {{#foreach 경로}}...{{/foreach}} → GRID 배열 반복 (쉼표 구분)

JSON/XML 템플릿 모두 지원

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:42:32 +09:00
curry772 c4010147db 테스트 거래 시 forwardproxy 사용하지 않도록 변경 2026-05-11 16:17:26 +09:00
curry772 5994befbb6 테스트 거래 시 forwardproxy 사용하지 않도록 변경 2026-05-11 16:16:26 +09:00
curry772 07569e62da HTTP도 REST 처럼 simulator 호출 기능, mock server(test master) 호출로 변경 2026-05-11 16:11:10 +09:00
curry772 e5638f22bb Outbound HeaderGroup 전체 전달 'ALL' 지원 2026-05-11 16:10:20 +09:00
curry772 e64319e7d2 Merge branch 'feature/inflow-control-improvement' into master 2026-05-08 10:51:21 +09:00
curry772 43548a90c4 fix: AbstractInflowControlManager Bandwidth.simple → classic+intervally 버킷 생성 버그 수정 및 테스트 추가
- makeBucketUsingInflowVO: 어댑터/인터페이스 단일 버킷 threshold 리필을 greedy → intervally 변경
- makeGroupBucket: 그룹 threshold 버킷 동일하게 수정
- DualInflowControlManagerMakeBucketTest: 버킷 생성 로직 직접 검증 테스트 추가
- InflowControlManagerTest: @BeforeEach reload + spike/burst 경계 케이스 테스트 추가
- SQL 픽스처: MIN 단위 TEST_ADAPTER/TEST_INTERFACE/test-group-quota 항목 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:21:57 +09:00
curry772 8900966d71 refactor: DualBucket/DualInflowControlManager 클라이언트 분리 및 Bandwidth 방식 변경
- DualBucket 인터페이스에서 클라이언트 메서드 분리 → ClientDualBucket 신규 인터페이스
- DualInflowControlManager 클라이언트 구현 분리 → ClientDualInflowControlManager
- DualInflowControlManager @Component 제거 (ClientDualInflowControlManager가 단일 빈)
- makeBucket: Bandwidth.simple → Bandwidth.classic + Refill.greedy/intervally 변경
  (threshold 버킷을 진짜 기간 쿼터로 동작하도록 수정)
- DualRequestProcessor → eapim-online custom 패키지로 이동 (삭제)
- ReloadInflowClientControlCommand / RemoveInflowClientControlCommand:
  instanceof 타입을 ClientDualInflowControlManager로 변경
- 단위 테스트: ClientDualInflowControlManagerMetricsTest 추가,
  DualInflowControlManagerMetricsTest 클라이언트 케이스 분리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:02:34 +09:00
curry772 32a7723fa4 Merge branch 'feature/crypto-module' into master 2026-05-07 15:38:47 +09:00
curry772 580686886c feat: HTTP 어댑터 암복호화 필터 기반 클래스 추가 및 ReloadCryptoModuleCommand 개선
- CryptoFilter (abstract): BODY/FIELD scope 암복호화, buildRuntimeContext 추상화
  - String/byte[]/JSONObject 타입 message 처리 (toBodyString)
  - STATIC/DYNAMIC 키 모듈 공통 지원 (CryptoModuleManager가 내부 분기)
- ReloadCryptoModuleCommand: 로깅 추가, 성공 시 "success" 반환, 에러코드 정의

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 15:03:47 +09:00
curry772 bf1135f9ad feat: 암호화모듈 프레임워크 신규 구현
- CryptoModuleExtension 인터페이스: BC 프로바이더, AAD, 편의 메서드 오버로드 추가
- AESCryptoModuleExtension: CBC/GCM/ECB/FF1 지원, GCM 랜덤 nonce, AAD 처리
- ARIACryptoModuleExtension: ARIA 알고리즘 지원
- CryptoModuleConfigVO: JPA Entity 분리를 위한 Lombok VO
- CryptoModuleManager: VO 패턴 적용, FQCN 기반 전략 동적 로딩(Class.forName)
- CryptoModuleService: STATIC/DYNAMIC × AAD 조합 8가지 오버로드
- CryptoModuleConfigMapper: MapStruct Entity→VO 매퍼
- KeyDerivationStrategy 인터페이스 및 DerivedKey
- HsmContextXorKeyDerivationStrategy: HSM 마스터키 XOR 컨텍스트값 전략
- ReloadCryptoModuleCommand: 설정 런타임 재로드 커맨드
- build.gradle: BouncyCastle bcprov-jdk15on:1.70 의존성 추가
- 단위 테스트 전체 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:49:33 +09:00
curry772 8f70451477 Merge branch 'feature/inflow-control-improvement'
- AbstractInflowControlManager 상속 구조 리팩토링
- DualInflowControlManager DAO 이중 호출 제거 및 타겟 메트릭 추가
- ReloadInflowClientControlCommandTest / RemoveInflowClientControlCommandTest 수정
2026-04-30 10:16:52 +09:00
curry772 437961e7d5 test: ReloadInflowClientControlCommandTest / RemoveInflowClientControlCommandTest 수정
PropManager NPE 및 정적 캐시 미초기화로 인한 테스트 실패 수정.
InflowControlUtil.cachedInflowControlManager를 ReflectionTestUtils로
직접 주입/초기화하는 방식으로 변경.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:15:27 +09:00
curry772 496bdd1468 feat: DualInflowControlManager DAO 이중 호출 제거 및 타겟 메트릭 추가
- 베이스 맵 메서드 오버라이드(getAdapterAllKeys, getInterfaceAllKeys,
  getAdapterInflowThreashold, getInterfaceInflowThreashold,
  removeAdapter, removeInterface)로 DAO 이중 호출 제거
- super.reload*() 호출 제거
- 어댑터/인터페이스/클라이언트별 TargetMetrics 수집 추가
  (allowed, rejectedPerSecond, rejectedThreshold)
- 메트릭 조회/초기화 API 메서드 추가
- 단위테스트: DualInflowControlManagerBucketMethodsTest(18건),
  DualInflowControlManagerMetricsTest 확장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:15:04 +09:00
curry772 2bfc3d0bde refactor: AbstractInflowControlManager 상속 구조 리팩토링 및 커맨드 클래스 타입 변경
InflowControlManager에서 공통 로직을 AbstractInflowControlManager로 분리하고,
커맨드 클래스들이 AbstractInflowControlManager 타입을 사용하도록 변경.
InflowControlUtil에 cachedInflowControlManager 정적 캐시 도입.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:14:31 +09:00
curry772 9589bc635a test: 클라이언트 유량제어 Command 단위 테스트 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:15:11 +09:00
curry772 ca8be26583 feat: 클라이언트 유량제어 Command 추가 및 removeClient() 구현
- ReloadInflowClientControlCommand: 클라이언트 버킷 전체/개별 리로드
- RemoveInflowClientControlCommand: 클라이언트 버킷 제거
- DualInflowControlManager.removeClient(clientId) 메서드 추가
  (기존 removeAdapter/removeInterface와 동일한 패턴)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:59:15 +09:00
curry772 5641baff35 Merge branch 'feature/circuit-breaker-test' into master 2026-04-29 14:47:15 +09:00
curry772 f36f78d63c refactor: TypedCircuitBreakerManager 미사용 코드 정리
- urlCBConfigMap 선언 주석 처리 (URL 기반 CB 기능 미사용)
- AlarmStateManager 연동 코드 주석 처리 (AlarmEvent/AlarmCondition/CoreAlarmKey import 제거)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:01 +09:00
curry772 06f0d389a6 test: TypedCircuitBreakerManager 단위테스트 추가 및 idCBConfigMap 버그 수정
- TypedCircuitBreakerManagerTest 추가 (JUnit5/Mockito, DB/Spring 없이 동작)
- getCircuitBreaker 조회/fallback/비활성화/normalizeUseYn 검증 (1-1~1-5)
- apiId 별 CB 인스턴스 독립성 및 상태 격리 검증 (2-1~2-2)
- CLOSED/OPEN/HALF_OPEN 상태 전이 전체 검증 (3-1~3-6)
- reload(key) 설정 갱신/비활성화 제거, reload() 레지스트리 재생성 검증 (4-1~4-3)
- 생명주기 isStarted 검증 (5-1)
- init()에서 idCBConfigMap.remove를 put으로 수정 (reload(key) 동작 오류 버그 수정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:32:44 +09:00
curry772 00735f6614 Merge branch 'feature/hsm-integration-jdk8' into master 2026-04-29 12:59:07 +09:00
curry772 a1a5a34874 feat: HSM 키 캐시 및 PropManager 연동 초기화, DB 설정 파싱 오류 수정
- HsmCryptoService: getPublicKey/getSecretKey ConcurrentHashMap 캐시 추가 (HSM 통신 최소화)
- HsmCryptoService: encryptAes/decryptAes Provider 파라미터 오버로드 추가 (non-extractable 키 지원)
- HsmCryptoService: PropertyChangeListener 구현, HSM.CACHE_RELOAD_YN=Y 시 clearKeyCache() 자동 호출
- HsmCryptoService: @PostConstruct NPE 수정 - PropManager.getInstance() → @Autowired 직접 주입
- HsmManager: DB PKCS11_CONFIG 리터럴 \n → 실제 줄바꿈 치환 (파싱 오류 수정)
- HsmManager: 기동 시 HSM 키 목록 로그 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:58:50 +09:00
curry772 acdb58049c feat: SafeNet HSM 연동 구현 (JDK 8 / SunPKCS11)
- HsmManager: SunPKCS11 Provider 초기화, Lifecycle 관리
  - JDK 8 / JDK 9+ 분기를 리플렉션으로 처리하는 createProvider() 추가
  - PropManager DB에서 PKCS11_CONFIG / PIN 읽어 초기화
- HsmCryptoService: RSA / AES 암복호화 서비스
  - encryptRsa: 공개키 기반 JVM 소프트웨어 암호화
  - decryptRsa: HSM 내부 복호화 (미초기화 시 HsmException 발생)
  - encryptAes / decryptAes: AES/CBC/PKCS5Padding
- HsmException: HSM 전용 커스텀 예외
- pkcs11-dev.cfg: SoftHSM2용 (slotListIndex = 0)
- pkcs11-prod.cfg: SafeNet ProtectServer 운영용 (slot = 0)
- HsmSoftHsm2IntegrationTest: 저수준 PKCS11 직접 테스트 (8개)
- HsmManagerServiceTest: HsmManager/HsmCryptoService 경유 테스트 (8개)
  Mockito + GenericApplicationContext로 DB 없이 실행 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:52:14 +09:00
curry772 81f7898eb1 Merge branch 'feature/inflow-control-improvement' into master 2026-04-27 17:24:58 +09:00
curry772 d08c7efcdc refactor: DualRequestProcessor를 inbound.processor 패키지로 이동
유량제어 컴포넌트(inflow.dual)가 아닌 요청 처리기이므로
부모 클래스(RequestProcessor)와 동일 패키지로 이동.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:50:48 +09:00
curry772 a4c9a2f2bd feat: Dual 이중 버킷 유량제어 패키지 추가 (DJErp→Dual 리네임·이관)
- InflowType 열거형 추가 (ADAPTER/INTERFACE/CLIENT 타입 코드 캡슐화)
- InflowControlDAO: InflowType 기반 범용 메서드 추가, 기존 메서드 위임
- RequestProcessor: private→protected 전환, 클라이언트/어댑터/인터페이스
  유량제어 훅 메서드 5개 추가 (checkClientInflow 등)
- dual 패키지 신규 추가
  · DualCustomBucket: perSecond/threshold 이중 버킷, 차단 원인 구분
  · DualBucket: isAdapterPassDetail 등 차단 원인 반환 인터페이스
  · DualInflowControlManager: 그룹 메트릭 카운팅(TargetMetrics), 리로드 연동
  · DualRequestProcessor: 클라이언트 유량제어 + 상세 오류 메시지 훅 구현
- 단위 테스트 5종 추가 (DualCustomBucket, Manager, Metrics, RequestProcessor, Concurrency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:40:27 +09:00
curry772 632550220d fix: Reload 동시성 수정 - volatile ConcurrentHashMap + 원자적 맵 교체
1. 맵 5개를 volatile + ConcurrentHashMap으로 변경
   - 비동기 읽기(isGroupPass 등) 중 HashMap 구조 변경으로 발생하던 race condition 제거

2. initAdapter/Interface/Group()에서 빌드-완성 후 원자적 교체 패턴 적용
   - 신규 맵을 완전히 구성한 뒤 this.field = newMap으로 한 번에 교체
   - 구→신 전환 중 불완전 상태가 외부에 노출되던 문제 제거

3. removeAdapter/Interface/Group()에 synchronized 추가
   - 락 없이 맵을 수정하던 메서드와 reload 사이의 race condition 방지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:25:58 +09:00
curry772 d88dbe1eef feat: 리플렉션 제거 - InflowControlManager.getGroupBucket() public 메서드 추가
groupBucketList private 필드에 리플렉션 없이 접근할 수 있도록
getGroupBucket(String groupId) public 메서드 제공.

그룹 테이블 테스트 픽스처 SQL 추가 및 InflowControlManagerTest에
getGroupBucket() 동작 검증 테스트 케이스 2개 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:21:00 +09:00
curry772 31b8a2b130 Merge branch 'feature/standard-message-djb' 2026-04-23 14:14:54 +09:00
76 changed files with 10140 additions and 631 deletions
+2
View File
@@ -82,6 +82,8 @@ dependencies {
api 'com.nimbusds:nimbus-jose-jwt:9.24.3'
api 'org.bouncycastle:bcprov-jdk15on:1.70'
api "io.undertow:undertow-servlet:${undertowVersion}"
api 'org.java-websocket:Java-WebSocket:1.3.9'
api 'javax.cache:cache-api:1.1.1'
@@ -0,0 +1,319 @@
package com.eactive.eai.adapter.handler;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.property.PropManager;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
/**
* 템플릿 기반 어댑터 에러 메시지 핸들러.
*
* [프로퍼티 설정]
* AdapterPropManager 의 "AdapterErrorMessageHandler" 프로퍼티 그룹에서
* "{adapterGroupId}.template" 키로 등록된 템플릿 문자열을 읽어
* StandardMessage 의 필드 값으로 치환한 결과를 반환한다.
*
* [변수 치환 규칙]
* 1. "${callprop.키}" → callProp.getProperty("키") 로 치환
* "${callprop.키:기본값}" → 키 없거나 callProp null 이면 기본값 반환
* 2. "${callprop[경로]}" → StandardMessage 경로 값을 키로 callProp 간접 조회
* "${callprop[경로]:기본값}" → 키 결정 실패 또는 키 없으면 기본값 반환
* 3. "${경로}" → StandardMessage.findItemValue("경로") 로 치환
* "${경로:기본값}" → 경로 값이 null/공백이면 기본값 반환
* 모든 형태에서 기본값 미지정 시 빈 문자열을 반환한다.
*
* [배열 반복 문법] (MSG_LIST 등 GRID 타입)
* {{#foreach MSG.MSG_LIST}}
* { "code":"${outp_msg_cd}", "msg":"${outp_msg_ctnt}" }
* {{/foreach}}
* → MSG_LIST 각 행을 반복하며 행 내 필드명(접두어 없이)으로 치환,
* 반복 결과를 쉼표(,)로 이어 붙임
*
* [사용 예 JSON 템플릿]
* {
* "adapterName": "${callprop.ADAPTER_NAME}",
* "resultCode": "${MSG.MAIN_MSG.outp_msg_cd}",
* "resultMsg": "${MSG.MAIN_MSG.outp_msg_ctnt}",
* "errors": [
* {{#foreach MSG.MSG_LIST}}
* {
* "code": "${outp_msg_cd}",
* "msg": "${outp_msg_ctnt}",
* "desc": "${outp_msg_desc}",
* "field": "${err_occu_item_nm}"
* }
* {{/foreach}}
* ]
* }
*
* [사용 예 – XML 템플릿]
* <error>
* <adapter>${callprop.ADAPTER_NAME}</adapter>
* <code>${MSG.MAIN_MSG.outp_msg_cd}</code>
* <errors>
* {{#foreach MSG.MSG_LIST}}
* <item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>
* {{/foreach}}
* </errors>
* </error>
*
* [callProp 중첩 Properties 참조]
* callProp 의 값이 Properties 인 경우 점(.) 으로 체인 탐색이 가능하다.
* callProp.put("OUTBOUND", subProps) // subProps.setProperty("IF_ID", "SVC001")
* → 템플릿에서 ${callprop.OUTBOUND.IF_ID} 로 참조
* 중간 값이 Properties 가 아닌 경우 전체 keyPath 를 키로 폴백 조회한다.
*/
public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandler {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
/** AdapterPropManager 에서 템플릿을 조회할 프로퍼티 그룹 이름 */
static final String PROP_GROUP = "AdapterErrorMessageHandler";
/** callProp 참조 변수 접두어: ${callprop.키} */
static final String CALLPROP_PREFIX = "callprop.";
/** callProp 간접 참조 접두어: ${callprop[표준전문경로]} */
static final String CALLPROP_INDIRECT_PREFIX = "callprop[";
/** ${path} 또는 ${callprop.key} 스칼라 변수 패턴 */
private static final Pattern VAR_PATTERN =
Pattern.compile("\\$\\{([^}]+)\\}");
/** {{#foreach path}}...{{/foreach}} 배열 반복 블록 패턴 */
private static final Pattern FOREACH_PATTERN =
Pattern.compile("\\{\\{#foreach\\s+([^}]+)\\}\\}(.*?)\\{\\{/foreach\\}\\}",
Pattern.DOTALL);
@Override
public Object generateNonStandardErrorResponseMessage(
String inboundAdapterGroupName,
String inboundAdapterName,
Properties callProp,
Object outboundRequestData,
StandardMessage resStandardMessage) throws Exception {
String templateKey = inboundAdapterGroupName + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template)) {
if (logger.isWarn()) {
logger.warn("TemplateAdapterErrorMsgHandler] template not found. group=" + PROP_GROUP
+ ", key=" + templateKey);
}
return null;
}
return render(template, resStandardMessage, callProp);
}
/**
* 템플릿에 StandardMessage 및 callProp 값을 치환해 최종 문자열을 반환한다.
* package-private: 단위 테스트에서 직접 호출 가능
*/
String render(String template, StandardMessage msg, Properties callProp) {
// 1단계: {{#foreach path}}...{{/foreach}} 배열 반복 처리
StringBuffer sb = new StringBuffer();
Matcher foreachMatcher = FOREACH_PATTERN.matcher(template);
while (foreachMatcher.find()) {
String arrayPath = foreachMatcher.group(1).trim();
String blockContent = foreachMatcher.group(2);
String rendered = renderForeach(arrayPath, blockContent, msg);
foreachMatcher.appendReplacement(sb, Matcher.quoteReplacement(rendered));
}
foreachMatcher.appendTail(sb);
// 2단계: 나머지 ${path} / ${callprop.키} 스칼라 변수 치환
String intermediate = sb.toString();
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(intermediate);
while (varMatcher.find()) {
String expr = varMatcher.group(1).trim();
String value = resolveScalar(expr, msg, callProp);
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString();
}
/**
* 변수 표현식을 해석해 값을 반환한다.
* 모든 형태에서 ':기본값' 접미사를 지원한다. 기본값 미지정 시 빈 문자열.
* - "callprop[경로][:기본값]" → 표준전문 경로 값을 키로 callProp 간접 조회
* - "callprop.키[:기본값]" → callProp 직접/중첩 조회
* - "경로[:기본값]" → StandardMessage.findItemValue(경로)
*/
private String resolveScalar(String expr, StandardMessage msg, Properties callProp) {
if (expr.startsWith(CALLPROP_INDIRECT_PREFIX)) {
return resolveIndirectCallProp(expr, msg, callProp);
}
if (expr.startsWith(CALLPROP_PREFIX)) {
String rest = expr.substring(CALLPROP_PREFIX.length());
int colonIdx = rest.indexOf(':');
String keyPath = colonIdx >= 0 ? rest.substring(0, colonIdx) : rest;
String defaultVal = colonIdx >= 0 ? rest.substring(colonIdx + 1) : "";
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, keyPath);
return value != null ? value : defaultVal;
}
int colonIdx = expr.indexOf(':');
String path = colonIdx >= 0 ? expr.substring(0, colonIdx) : expr;
String defaultVal = colonIdx >= 0 ? expr.substring(colonIdx + 1) : "";
String value = resolveMessagePath(msg, path);
return StringUtils.isNotEmpty(value) ? value : defaultVal;
}
/**
* StandardMessage 경로를 해석한다.
* 1. findItemValue(path) 로 직접 조회한다.
* 2. null 이면 경로를 뒤에서부터 분리하며, 부모 경로 값이 JSON 문자열인 경우
* 나머지 경로(subPath)를 키로 JSON 필드를 추출한다.
* 예) DATA.BIZDATA.acctNo → findItemValue("DATA.BIZDATA") → JSON 파싱 → acctNo
* DATA.BIZDATA.addr.city → findItemValue("DATA.BIZDATA") → JSON 파싱 → addr.city
*/
private String resolveMessagePath(StandardMessage msg, String path) {
String value = msg.findItemValue(path);
if (value != null) return value;
int dotIdx = path.lastIndexOf('.');
while (dotIdx > 0) {
String parentPath = path.substring(0, dotIdx);
String subPath = path.substring(dotIdx + 1);
String parentVal = msg.findItemValue(parentPath);
if (StringUtils.isNotBlank(parentVal)) {
String extracted = extractFromJson(parentVal, subPath);
if (extracted != null) return extracted;
}
dotIdx = parentPath.lastIndexOf('.');
}
return null;
}
/**
* JSON 문자열에서 dot 구분 경로로 필드 값을 추출한다.
* 중간 경로가 JSON 오브젝트면 계속 탐색하고, 최종 값이 primitive 면 문자열로,
* 오브젝트/배열이면 JSON 문자열 그대로 반환한다. 파싱 실패 시 null 반환.
*/
private String extractFromJson(String json, String fieldPath) {
try {
JsonElement el = new JsonParser().parse(json);
for (String part : fieldPath.split("\\.", -1)) {
if (!el.isJsonObject()) return null;
el = el.getAsJsonObject().get(part);
if (el == null) return null;
}
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
} catch (Exception e) {
return null;
}
}
/**
* ${callprop[경로]} 또는 ${callprop[경로]:기본값} 처리.
* 1. 표준전문 경로로 callProp 키를 동적으로 결정한다.
* 2. 결정된 키로 callProp 에서 값을 조회한다.
* 3. 키 또는 값을 찾지 못하면 기본값(없으면 빈 문자열)을 반환한다.
*/
private String resolveIndirectCallProp(String expr, StandardMessage msg, Properties callProp) {
int closeIdx = expr.indexOf(']');
if (closeIdx < 0) return "";
String msgPath = expr.substring(CALLPROP_INDIRECT_PREFIX.length(), closeIdx);
String defaultVal = (closeIdx + 1 < expr.length() && expr.charAt(closeIdx + 1) == ':')
? expr.substring(closeIdx + 2)
: "";
String key = msg.findItemValue(msgPath);
if (StringUtils.isBlank(key)) return defaultVal;
if (callProp == null) return defaultVal;
String value = resolveCallProp(callProp, key);
return value != null ? value : defaultVal;
}
/**
* Properties 체인을 점(.) 구분자로 재귀 탐색한다.
* - 첫 세그먼트의 값이 Properties 이면 나머지 경로로 재귀
* - String 이면 반환, 그 외 Object 이면 toString() 반환
* - 첫 세그먼트 값이 Properties 가 아닌 경우 전체 keyPath 로 폴백 조회
*/
private String resolveCallProp(Properties props, String keyPath) {
int dotIdx = keyPath.indexOf('.');
if (dotIdx < 0) {
Object val = props.get(keyPath);
if (val == null) return null;
if (val instanceof String) return (String) val;
return val.toString();
}
String first = keyPath.substring(0, dotIdx);
String rest = keyPath.substring(dotIdx + 1);
Object val = props.get(first);
if (val instanceof Properties) {
return resolveCallProp((Properties) val, rest);
}
// 중간 값이 Properties 가 아닌 경우: 전체 keyPath 를 키로 폴백
Object direct = props.get(keyPath);
if (direct == null) return null;
if (direct instanceof String) return (String) direct;
return direct.toString();
}
/**
* GRID 타입 배열 아이템을 반복하며 blockContent 를 각 행으로 치환,
* 결과를 쉼표로 이어 반환한다. (foreach 블록 내부는 callProp 참조 미지원)
*/
private String renderForeach(String arrayPath, String blockContent, StandardMessage msg) {
StandardItem arrayItem = msg.findItem(arrayPath);
if (arrayItem == null) {
if (logger.isWarn()) logger.warn("TemplateAdapterErrorMsgHandler] foreach path not found: " + arrayPath);
return "";
}
List<LinkedHashMap<String, StandardItem>> rows = arrayItem.getList();
if (rows == null || rows.isEmpty()) {
return "";
}
List<String> renderedItems = new ArrayList<>();
for (LinkedHashMap<String, StandardItem> row : rows) {
String rowText = renderRow(blockContent, row);
if (StringUtils.isNotBlank(rowText)) {
renderedItems.add(rowText);
}
}
return String.join(",", renderedItems);
}
/**
* 배열 한 행(LinkedHashMap) 기준으로 blockContent 의 ${fieldName} 을 치환한다.
* fieldName 은 경로 없이 해당 행의 컬럼명만 사용한다.
*/
private String renderRow(String blockContent, LinkedHashMap<String, StandardItem> row) {
StringBuffer result = new StringBuffer();
Matcher varMatcher = VAR_PATTERN.matcher(blockContent);
while (varMatcher.find()) {
String fieldName = varMatcher.group(1).trim();
String value = "";
StandardItem item = row.get(fieldName);
if (item != null) {
value = StringUtils.defaultString(item.getValue());
}
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
varMatcher.appendTail(result);
return result.toString().trim();
}
}
@@ -40,7 +40,9 @@ import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.message.EAIMessageKeys;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoVO;
@@ -566,4 +568,10 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
}
public abstract Object execute(Properties prop, Object message, Properties tempProp) throws Exception;
protected boolean isTas(Properties tempProp) {
String simYn = tempProp.getProperty("SIM_YN");
return EAIServerManager.getInstance().isTASEnabledEAIServer()
&& StringUtils.equals(simYn, EAIMessageKeys.TRANTYPE_TAS);
}
}
@@ -41,14 +41,11 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
boolean useForwardProxy = StringUtils.equalsIgnoreCase(prop.getProperty("FORWARD_PROXY_USE_YN", "N"), "Y");
String forwardProxyUrl = prop.getProperty("FORWARD_PROXY_URL");
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -11,6 +11,7 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -294,14 +295,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
if(useForwardProxy) {
if(useForwardProxy && !isTas(tempProp)) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol,host, port);
HttpHost proxy = new HttpHost(url.getProtocol(), url.getHost(), url.getPort());
requestConfigBuilder.setProxy(proxy);
}
@@ -708,12 +706,19 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
return new String(responseMessage, encode);
}
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
JSONObject headerJson = new JSONObject();
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(key, value);
if (StringUtils.equalsIgnoreCase(relayHeaderKeys, "ALL")) {
for (Enumeration<Object> e = responseHeaderProp.keys(); e.hasMoreElements(); ) {
String key = (String)e.nextElement();
headerJson.put(StringUtils.lowerCase(key), responseHeaderProp.getProperty(key));
}
} else {
String[] relayKeyArr = org.springframework.util.StringUtils.tokenizeToStringArray(relayHeaderKeys, ",");
for (String key : relayKeyArr) {
String value = responseHeaderProp.getProperty(key);
if (StringUtils.isNotBlank(value)) {
headerJson.put(StringUtils.lowerCase(key), value);
}
}
}
@@ -0,0 +1,21 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
/**
* HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스.
*
*/
public class BaseCryptoFilter extends CryptoFilter {
/**
* 빈 Runtime Context를 생성한다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
return new HashMap<>();
}
}
@@ -0,0 +1,232 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.security.CryptoModuleService;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.JsonPathUtil;
/**
* HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스.
*
* 서브클래스는 {@link #buildRuntimeContext}를 구현하여
* DYNAMIC 키 도출에 필요한 컨텍스트 맵을 제공한다.
* STATIC 키 모듈을 사용하는 경우 빈 Map을 반환하면 된다.
* (CryptoModuleManager가 KEY_SOURCE_TYPE=STATIC이면 runtimeContext를 무시하고 고정 키를 사용한다.)
*
* 어댑터 프로퍼티 키:
* CRYPTO_MODULE_NAME 필수. DB에 등록된 암호화 모듈명.
* CRYPTO_SCOPE BODY | FIELD (기본 FIELD)
* BODY : 메시지 전체를 암복호화 (Base64 인코딩/디코딩)
* FIELD : 특정 JSON 경로의 값만 암복호화
*
* FIELD 범위 전용:
* CRYPTO_DEC_FROM_PATH 복호화 대상 JSON 경로 (예: /encrypted_data)
* CRYPTO_DEC_TO_PATH 복호화 결과 반영 경로. "/" = 전체 body 교체.
* CRYPTO_ENC_FROM_PATH 암호화 대상 JSON 경로. "/" = 전체 body 암호화.
* CRYPTO_ENC_TO_PATH 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encrypted_data)
*
* GCM AAD 전용 (선택):
* CRYPTO_AAD_HEADER AAD로 사용할 HTTP 요청 헤더명 (예: X-Api-Request-Id).
* 미설정 또는 헤더값 없으면 aad=null → IV를 AAD 대체값으로 사용.
*/
public abstract class CryptoFilter implements HttpAdapterFilter {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
public static final String PROP_MODULE_NAME = "CRYPTO_MODULE_NAME";
public static final String PROP_SCOPE = "CRYPTO_SCOPE";
public static final String PROP_DEC_FROM_PATH = "CRYPTO_DEC_FROM_PATH";
public static final String PROP_DEC_TO_PATH = "CRYPTO_DEC_TO_PATH";
public static final String PROP_ENC_FROM_PATH = "CRYPTO_ENC_FROM_PATH";
public static final String PROP_ENC_TO_PATH = "CRYPTO_ENC_TO_PATH";
public static final String PROP_AAD_HEADER = "CRYPTO_AAD_HEADER";
public static final String SCOPE_BODY = "BODY";
public static final String SCOPE_FIELD = "FIELD";
public static final String PATH_ROOT = "/";
public static final String PATH_ENCDATA = "/encrypted_data";
// ------------------------------------------------------------------
// 추상 메서드
// ------------------------------------------------------------------
/**
* DYNAMIC 키 도출에 필요한 runtimeContext 맵을 구성한다.
* STATIC 키 모듈이면 빈 Map({@code new HashMap<>()})을 반환한다.
*/
protected abstract Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request);
// ------------------------------------------------------------------
// doPreFilter: 요청 복호화
// ------------------------------------------------------------------
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return decryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_DEC_FROM_PATH, PATH_ENCDATA),
getProp(prop, PROP_DEC_TO_PATH, PATH_ROOT));
}
return decryptBody(body, moduleName, runtimeCtx, aad);
}
// ------------------------------------------------------------------
// doPostFilter: 응답 암호화
// ------------------------------------------------------------------
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(resultMessage);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return encryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_ENC_FROM_PATH, PATH_ROOT),
getProp(prop, PROP_ENC_TO_PATH, PATH_ENCDATA));
}
return encryptBody(body, moduleName, runtimeCtx, aad);
}
// ------------------------------------------------------------------
// 복호화 구현
// ------------------------------------------------------------------
private String decryptBody(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad) throws Exception {
byte[] cipherBytes = Base64.getDecoder().decode(body.trim());
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
return new String(plainBytes, StandardCharsets.UTF_8);
}
/**
* fromPath의 Base64 값을 복호화하여 toPath에 반영.
* toPath = "/" → 복호화된 텍스트로 body 전체 교체.
*/
private String decryptField(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad,
String fromPath, String toPath) throws Exception {
String encBase64 = JsonPathUtil.getValueAtPath(body, fromPath);
if (StringUtils.isBlank(encBase64)) {
logger.warn("CryptoFilter] 복호화 대상 필드 없음: path=" + fromPath);
return body;
}
byte[] cipherBytes = Base64.getDecoder().decode(encBase64.trim());
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
String plainText = new String(plainBytes, StandardCharsets.UTF_8);
if (PATH_ROOT.equals(toPath)) {
return plainText;
}
return JsonPathUtil.setValueAtPath(body, toPath, plainText, false);
}
// ------------------------------------------------------------------
// 암호화 구현
// ------------------------------------------------------------------
private String encryptBody(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad) throws Exception {
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
body.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(cipherBytes);
}
/**
* fromPath 값을 암호화하여 toPath(Base64)에 반영.
* fromPath = "/" → body 전체를 암호화하여 toPath 필드명으로 래핑.
*/
private String encryptField(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad,
String fromPath, String toPath) throws Exception {
String plainText;
if (PATH_ROOT.equals(fromPath)) {
plainText = body;
} else {
plainText = JsonPathUtil.getValueAtPath(body, fromPath);
if (StringUtils.isBlank(plainText)) {
logger.warn("CryptoFilter] 암호화 대상 필드 없음: path=" + fromPath);
return body;
}
}
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
plainText.getBytes(StandardCharsets.UTF_8));
String encBase64 = Base64.getEncoder().encodeToString(cipherBytes);
if (PATH_ROOT.equals(fromPath)) {
String fieldName = toPath.replaceFirst("^/", "");
return "{\"" + fieldName + "\":\"" + encBase64 + "\"}";
}
return JsonPathUtil.setValueAtPath(body, toPath, encBase64, false);
}
// ------------------------------------------------------------------
// 유틸
// ------------------------------------------------------------------
/**
* 필터로 전달되는 message 객체를 JSON 문자열로 변환한다.
* <ul>
* <li>{@code String} — 그대로 반환</li>
* <li>{@code byte[]} — UTF-8 디코딩</li>
* <li>{@code JSONObject} / 기타 — {@code toString()} 호출
* (json-simple JSONObject는 toString()이 toJSONString()을 위임함)</li>
* </ul>
*/
protected String toBodyString(Object message) {
if (message instanceof String) {
return (String) message;
}
if (message instanceof byte[]) {
return new String((byte[]) message, StandardCharsets.UTF_8);
}
return message.toString();
}
/**
* crypto.aad.header 프로퍼티에 지정된 헤더값을 UTF-8 바이트로 반환한다.
* 프로퍼티 미설정 또는 헤더값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용)을 따른다.
*/
protected byte[] buildAad(Properties prop, HttpServletRequest request) {
String headerName = prop.getProperty(PROP_AAD_HEADER);
if (StringUtils.isBlank(headerName) || request == null) {
return null;
}
String headerValue = request.getHeader(headerName);
return StringUtils.isBlank(headerValue) ? null : headerValue.getBytes(StandardCharsets.UTF_8);
}
protected String required(Properties prop, String key) {
String v = prop.getProperty(key);
if (StringUtils.isBlank(v)) {
throw new IllegalArgumentException("CryptoFilter 필수 프로퍼티 누락: " + key);
}
return v;
}
private String getProp(Properties prop, String key, String defaultVal) {
String v = prop.getProperty(key);
return StringUtils.isBlank(v) ? defaultVal : v;
}
}
@@ -6,7 +6,8 @@ import com.eactive.eai.common.util.Logger;
public class HttpAdapterFilterFactoryKjb extends HttpAdapterFilterFactory {
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
static String basePackage = "com.eactive.eai.custom.adapter.http.dynamic.filter";
static String basePackage = "com.eactive.eai.adapter.http.dynamic.filter";
static String customBasePackage = "com.eactive.eai.custom.adapter.http.dynamic.filter";
static private ConcurrentHashMap<String, HttpAdapterFilter> h = new ConcurrentHashMap<String, HttpAdapterFilter>();
public HttpAdapterFilterFactoryKjb() {
@@ -44,6 +45,9 @@ public class HttpAdapterFilterFactoryKjb extends HttpAdapterFilterFactory {
if (filter == null) {
filter = classForName(basePackage + "." + type);
}
if (filter == null) {
filter = classForName(customBasePackage + "." + type);
}
break;
}
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowAdapterControlCommand extends Command {
@@ -27,7 +28,7 @@ public class ReloadInflowAdapterControlCommand extends Command {
String keyName = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
if (keyName != null) {
if ("ALL".equals(keyName)) {
@@ -0,0 +1,60 @@
package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowClientControlCommand extends Command {
private static final long serialVersionUID = 1L;
public ReloadInflowClientControlCommand() {
super("ReloadInflowClientControlCommand");
}
public Object execute() throws CommandException {
Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
if (logger.isInfo())
logger.info(this.name + " is executed");
if (!(args instanceof String)) {
String rspErrorCode = "RECEAIMCM001";
String msg = makeException(rspErrorCode, null);
if (logger.isError())
logger.error(msg);
throw new CommandException(msg);
}
String keyName = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
if (keyName != null) {
if ("ALL".equals(keyName)) {
manager.reloadClient();
if (logger.isWarn())
logger.warn(this.name + "] all rule Reload.");
} else {
manager.reloadClient(keyName);
if (logger.isWarn())
logger.warn(this.name + "] " + keyName + " Reload.");
}
}
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
String msg = makeException(rspErrorCode, e);
if (logger.isError())
logger.error(msg, e);
throw new CommandException(msg);
}
return "success";
}
}
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowGroupControlCommand extends Command {
@@ -28,7 +29,7 @@ public class ReloadInflowGroupControlCommand extends Command {
String keyName = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
if (keyName != null) {
if ("ALL".equals(keyName)) {
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class ReloadInflowInterfaceControlCommand extends Command {
@@ -28,7 +29,7 @@ public class ReloadInflowInterfaceControlCommand extends Command {
String keyName = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
if (keyName != null) {
if ("ALL".equals(keyName)) {
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowAdapterControlCommand extends Command {
@@ -26,7 +27,7 @@ public class RemoveInflowAdapterControlCommand extends Command {
String key = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
manager.removeAdapter(key);
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
@@ -0,0 +1,50 @@
package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowClientControlCommand extends Command {
private static final long serialVersionUID = 1L;
public RemoveInflowClientControlCommand() {
super("RemoveInflowClientControlCommand");
}
public Object execute() throws CommandException {
Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
if (logger.isInfo())
logger.info(this.name + " is executed");
if (!(args instanceof String)) {
String rspErrorCode = "RECEAIMCM001";
String msg = makeException(rspErrorCode, null);
if (logger.isError())
logger.error(msg);
throw new CommandException(msg);
}
String key = (String) args;
try {
AbstractInflowControlManager base = InflowControlUtil.getInflowControlManager();
if (!(base instanceof ClientDualInflowControlManager)) {
throw new IllegalStateException("ClientDualInflowControlManager 가 활성화되지 않았습니다.");
}
ClientDualInflowControlManager manager = (ClientDualInflowControlManager) base;
manager.removeClient(key);
if (logger.isWarn())
logger.warn(this.name + "] " + key + " removed.");
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
String msg = makeException(rspErrorCode, e);
if (logger.isError())
logger.error(msg, e);
throw new CommandException(msg);
}
return "success";
}
}
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowGroupControlCommand extends Command {
@@ -28,7 +29,7 @@ public class RemoveInflowGroupControlCommand extends Command {
String key = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
manager.removeGroup(key);
if (logger.isWarn())
logger.warn(this.name + "] group " + key + " removed.");
@@ -2,7 +2,8 @@ package com.eactive.eai.agent.inflow;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.util.Logger;
public class RemoveInflowInterfaceControlCommand extends Command {
@@ -25,7 +26,7 @@ public class RemoveInflowInterfaceControlCommand extends Command {
String key = (String) args;
try {
InflowControlManager manager = InflowControlManager.getInstance();
AbstractInflowControlManager manager = InflowControlUtil.getInflowControlManager();
manager.removeInterface(key);
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
@@ -0,0 +1,28 @@
package com.eactive.eai.agent.security;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.security.CryptoModuleManager;
import com.eactive.eai.common.util.Logger;
public class ReloadCryptoModuleCommand extends Command {
private static final long serialVersionUID = 1032982004418318100L;
@Override
public Object execute() throws CommandException {
Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
try {
String cryptoId = (String) args;
CryptoModuleManager.getInstance().reload(cryptoId);
return "success";
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
String msg = this.makeException(rspErrorCode, e);
if (logger.isError()) {
logger.error(msg, e);
}
throw new CommandException(e.getMessage());
}
}
}
@@ -17,10 +17,6 @@ import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.custom.alarm.AlarmStateManager;
import com.eactive.eai.custom.alarm.condition.AlarmCondition;
import com.eactive.eai.custom.alarm.event.AlarmEvent;
import com.eactive.eai.custom.alarm.key.CoreAlarmKey;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -45,7 +41,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
TypedCircuitBreakerVO defaultConfigVo;
HashMap<String, TypedCircuitBreakerVO> idCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> apiIdCBConfigMap = new HashMap<>();
HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
// HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
private boolean started;
@Autowired
@@ -116,7 +112,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
CircuitBreakerConfig circuitBreakerConfig = this.createCircuitBreakerConfig(vo);
vo.setCircuitBreakerConfig(circuitBreakerConfig);
this.apiIdCBConfigMap.put(vo.getName(), vo);
this.idCBConfigMap.remove(vo.getId(), vo);
this.idCBConfigMap.put(vo.getId(), vo);
}
}
@@ -359,14 +355,15 @@ public class TypedCircuitBreakerManager implements Lifecycle {
// } else {
// this.sendAlert(stateTransitionEvent);
// }
AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
.condition(AlarmCondition.builder().build())
.message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
.build();
AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
// AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
// .condition(AlarmCondition.builder().build())
// .message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
// .build();
//
// AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
TypedCircuitBreakerManager.logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
} else {
TypedCircuitBreakerManager.logger.error(
@@ -0,0 +1,266 @@
package com.eactive.eai.common.hsm;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.PostConstruct;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.Logger;
/**
* SafeNet ProtectServer HSM 암복호화 서비스 (JDK 8)
*
* [키 조회]
* getPublicKey - 공개키 반환 (외부 노출). 최초 1회만 HSM 통신, 이후 캐시 반환.
* getSecretKey - AES 대칭키 반환 (CKA_EXTRACTABLE=true 필요). 최초 1회만 HSM 통신, 이후 캐시 반환.
*
* [암복호화]
* encryptRsa - 공개키로 JVM 소프트웨어 암호화 (HSM 불필요)
* decryptRsa - alias 기반, HSM 내부 복호화 (매 호출마다 HSM 통신 발생 - 불가피)
* encryptAes - SecretKey 객체로 AES/CBC 암호화
* decryptAes - SecretKey 객체로 AES/CBC 복호화
*/
@Service
public class HsmCryptoService implements PropertyChangeListener {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String RSA_ALGORITHM = "RSA/ECB/PKCS1Padding";
private static final String AES_ALGORITHM = "AES/CBC/PKCS5Padding";
private static final String PROP_GROUP = "HSM";
private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN";
// HSM 통신 최소화: 최초 1회 조회 후 JVM 메모리에 캐싱
private final ConcurrentHashMap<String, SecretKey> secretKeyCache = new ConcurrentHashMap<String, SecretKey>();
private final ConcurrentHashMap<String, PublicKey> publicKeyCache = new ConcurrentHashMap<String, PublicKey>();
@Autowired
private PropManager propManager;
@PostConstruct
public void registerPropertyChangeListener() {
propManager.addPropertyChangeListener(this);
logger.warn("HsmCryptoService] PropManager PropertyChangeListener 등록 완료");
}
/**
* 관리포털에서 PropManager.reload("HSM") 호출 시 이벤트를 수신한다.
* HSM.CACHE_RELOAD_YN = Y 이면 키 캐시를 초기화한다.
*/
@Override
public void propertyChange(PropertyChangeEvent evt) {
if (!PROP_GROUP.equals(evt.getPropertyName())) {
return;
}
String reloadYn = propManager.getProperty(PROP_GROUP, PROP_CACHE_RELOAD_YN, "N");
if ("Y".equalsIgnoreCase(reloadYn)) {
clearKeyCache();
}
}
// -------------------------------------------------------------------------
// 키 조회 (외부 노출)
// -------------------------------------------------------------------------
/**
* HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
*/
public PublicKey getPublicKey(String keyAlias) throws HsmException {
checkReady();
PublicKey cached = publicKeyCache.get(keyAlias);
if (cached != null) {
return cached;
}
try {
Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias);
if (cert == null) {
throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias);
}
PublicKey key = cert.getPublicKey();
publicKeyCache.put(keyAlias, key);
logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias);
return key;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("공개키 조회 실패: " + e.getMessage(), e);
}
}
/**
* HSM KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 키 생성 시 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 로 생성된 키만 반환됩니다.
*/
public SecretKey getSecretKey(String keyAlias) throws HsmException {
checkReady();
SecretKey cached = secretKeyCache.get(keyAlias);
if (cached != null) {
return cached;
}
try {
KeyStore keyStore = HsmManager.getInstance().getKeyStore();
java.security.Key key = keyStore.getKey(keyAlias, null);
if (!(key instanceof SecretKey)) {
throw new HsmException("AES 키를 찾을 수 없습니다 (CKA_EXTRACTABLE=true 확인 필요). alias=" + keyAlias);
}
SecretKey secretKey = (SecretKey) key;
secretKeyCache.put(keyAlias, secretKey);
logger.warn("HsmCryptoService] 대칭키 캐시 등록: alias=" + keyAlias);
return secretKey;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("AES 키 조회 실패: " + e.getMessage(), e);
}
}
/** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */
public void clearKeyCache() {
secretKeyCache.clear();
publicKeyCache.clear();
logger.warn("HsmCryptoService] 키 캐시 초기화 완료");
}
// -------------------------------------------------------------------------
// RSA
// -------------------------------------------------------------------------
/**
* RSA 암호화 - HSM 인증서의 공개키로 JVM 소프트웨어 암호화.
* 공개키는 HSM 에서 추출하므로 HsmManager 가 초기화되어 있어야 합니다.
*/
public byte[] encryptRsa(String keyAlias, byte[] plaintext) throws HsmException {
return encryptRsa(getPublicKey(keyAlias), plaintext);
}
/**
* RSA 암호화 - 전달받은 공개키로 JVM 소프트웨어 암호화.
* HSM 없이도 호출 가능합니다.
*/
public byte[] encryptRsa(PublicKey publicKey, byte[] plaintext) throws HsmException {
try {
// Provider 미명시 → JVM 소프트웨어 Provider(SunRsaSign) 자동 선택
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return cipher.doFinal(plaintext);
} catch (Exception e) {
throw new HsmException("RSA 암호화 실패: " + e.getMessage(), e);
}
}
/**
* RSA 복호화 - 개인키 핸들을 HSM 에서 가져와 HSM 내부에서 복호화.
* HsmManager 가 초기화되지 않은 경우 JVM 소프트웨어로 fallback 합니다.
*
* @param keyAlias HSM KeyStore 의 개인키 별칭
* @param pin HSM 슬롯 PIN (null 이면 HsmManager 초기화 시 사용한 PIN 재사용)
* @param ciphertext 암호문 바이트
*/
public byte[] decryptRsa(String keyAlias, char[] pin, byte[] ciphertext) throws HsmException {
try {
HsmManager hsmManager = HsmManager.getInstance();
if (hsmManager.isReady()) {
// HSM 내부 복호화: pkcs11Provider 명시 필수
Provider pkcs11Provider = hsmManager.getPkcs11Provider();
java.security.Key key = hsmManager.getKeyStore().getKey(keyAlias, pin);
if (!(key instanceof PrivateKey)) {
throw new HsmException("개인키를 찾을 수 없습니다. alias=" + keyAlias);
}
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM, pkcs11Provider);
cipher.init(Cipher.DECRYPT_MODE, (PrivateKey) key);
return cipher.doFinal(ciphertext);
} else {
throw new HsmException("RSA 복호화 불가: HsmManager 가 초기화되지 않았습니다. alias=" + keyAlias);
}
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("RSA 복호화 실패: " + e.getMessage(), e);
}
}
// -------------------------------------------------------------------------
// AES
// -------------------------------------------------------------------------
/**
* AES/CBC/PKCS5Padding 암호화.
* extractable 키: provider=null 로 JVM 소프트웨어 암호화.
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
*
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
* @param iv 초기화 벡터 (16바이트)
* @param plaintext 평문 바이트
* @param provider null 이면 JVM 기본 Provider 사용
*/
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext, Provider provider) throws HsmException {
try {
Cipher cipher = (provider != null)
? Cipher.getInstance(AES_ALGORITHM, provider)
: Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher.doFinal(plaintext);
} catch (Exception e) {
throw new HsmException("AES 암호화 실패: " + e.getMessage(), e);
}
}
/** AES 암호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
public byte[] encryptAes(SecretKey secretKey, byte[] iv, byte[] plaintext) throws HsmException {
return encryptAes(secretKey, iv, plaintext, null);
}
/**
* AES/CBC/PKCS5Padding 복호화.
* extractable 키: provider=null 로 JVM 소프트웨어 복호화.
* non-extractable 키: provider 에 pkcs11Provider 를 전달해야 합니다.
*
* @param secretKey 대칭키 (HSM 추출 키 또는 소프트웨어 키)
* @param iv 초기화 벡터 (16바이트)
* @param ciphertext 암호문 바이트
* @param provider null 이면 JVM 기본 Provider 사용
*/
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext, Provider provider) throws HsmException {
try {
Cipher cipher = (provider != null)
? Cipher.getInstance(AES_ALGORITHM, provider)
: Cipher.getInstance(AES_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
return cipher.doFinal(ciphertext);
} catch (Exception e) {
throw new HsmException("AES 복호화 실패: " + e.getMessage(), e);
}
}
/** AES 복호화 - extractable 키 전용 (JVM 소프트웨어 Provider 사용). */
public byte[] decryptAes(SecretKey secretKey, byte[] iv, byte[] ciphertext) throws HsmException {
return decryptAes(secretKey, iv, ciphertext, null);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void checkReady() throws HsmException {
if (!HsmManager.getInstance().isReady()) {
throw new HsmException("HsmManager 가 초기화되지 않았습니다.");
}
}
}
@@ -0,0 +1,13 @@
package com.eactive.eai.common.hsm;
public class HsmException extends Exception {
private static final long serialVersionUID = 1L;
public HsmException(String msg) {
super(msg);
}
public HsmException(String msg, Throwable cause) {
super(msg, cause);
}
}
@@ -0,0 +1,183 @@
package com.eactive.eai.common.hsm;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
/**
* SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11)
*
* PropManager 그룹 "HSM" 에서 읽는 키:
* PKCS11_CONFIG - pkcs11.cfg 파일 내용 (name/library/slot 설정을 DB에 직접 저장)
* PIN - HSM 슬롯 PIN
*
* PKCS11_CONFIG 값 예시 (개행은 \n 으로 입력):
* name = ProtectServer
* library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so
* slot = 0
*/
@Component
public class HsmManager implements Lifecycle {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String GROUP_NAME = "HSM";
private static final String PROP_CONFIG = "PKCS11_CONFIG";
private static final String PROP_PIN = "PIN";
private Provider pkcs11Provider;
private KeyStore keyStore;
private boolean started;
private final LifecycleSupport lifecycle = new LifecycleSupport(this);
private HsmManager() {
}
public static HsmManager getInstance() {
return ApplicationContextProvider.getContext().getBean(HsmManager.class);
}
@Override
public void start() throws LifecycleException {
if (started) {
throw new LifecycleException("RECEAIHSM001");
}
lifecycle.fireLifecycleEvent(STARTING_EVENT, this);
try {
init();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002"));
}
started = true;
lifecycle.fireLifecycleEvent(STARTED_EVENT, this);
}
private void init() throws Exception {
String configContent = PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG);
String pin = PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN);
if (configContent == null || configContent.trim().isEmpty()) {
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다.");
return;
}
pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n"));
// 이미 등록된 Provider 가 있으면 제거 후 재등록
Provider existing = Security.getProvider(pkcs11Provider.getName());
if (existing != null) {
Security.removeProvider(existing.getName());
}
Security.addProvider(pkcs11Provider);
keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider);
char[] pinChars = (pin != null) ? pin.toCharArray() : null;
keyStore.load(null, pinChars);
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
java.util.Enumeration<String> aliases = keyStore.aliases();
StringBuilder aliasList = new StringBuilder();
while (aliases.hasMoreElements()) {
if (aliasList.length() > 0) aliasList.append(", ");
aliasList.append(aliases.nextElement());
}
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
}
/**
* JDK 버전에 따라 SunPKCS11 Provider 를 생성한다.
*
* JDK 8 : SunPKCS11(InputStream) 생성자를 리플렉션으로 호출
* JDK 9+: Provider.configure(configFilePath) 를 리플렉션으로 호출
*
* 두 경로 모두 리플렉션을 사용하므로 컴파일 타임에 JDK 버전 의존성이 없다.
*/
static Provider createProvider(String cfgContent) throws Exception {
String javaVersion = System.getProperty("java.version");
if (javaVersion.startsWith("1.")) {
// JDK 8: new SunPKCS11(InputStream)
InputStream is = new ByteArrayInputStream(cfgContent.getBytes("UTF-8"));
return (Provider) Class.forName("sun.security.pkcs11.SunPKCS11")
.getConstructor(InputStream.class)
.newInstance(is);
} else {
// JDK 9+: Security.getProvider("SunPKCS11").configure(configFilePath)
// configure() 는 JDK 9 에서 추가된 메서드이므로 리플렉션으로 호출
File tmp = File.createTempFile("pkcs11-hsm-", ".cfg");
tmp.deleteOnExit();
Files.write(tmp.toPath(), cfgContent.getBytes("UTF-8"));
Provider base = Security.getProvider("SunPKCS11");
Method configure = Provider.class.getMethod("configure", String.class);
return (Provider) configure.invoke(base, tmp.getAbsolutePath());
}
}
@Override
public void stop() throws LifecycleException {
if (!started) {
throw new LifecycleException("RECEAIHSM003");
}
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
if (pkcs11Provider != null) {
Security.removeProvider(pkcs11Provider.getName());
}
pkcs11Provider = null;
keyStore = null;
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
@Override
public void addLifecycleListener(LifecycleListener listener) {
lifecycle.addLifecycleListener(listener);
}
@Override
public LifecycleListener[] findLifecycleListeners() {
return lifecycle.findLifecycleListeners();
}
@Override
public void removeLifecycleListener(LifecycleListener listener) {
lifecycle.removeLifecycleListener(listener);
}
@Override
public boolean isStarted() {
return started;
}
public Provider getPkcs11Provider() {
return pkcs11Provider;
}
public KeyStore getKeyStore() {
return keyStore;
}
public boolean isReady() {
return started && pkcs11Provider != null && keyStore != null;
}
}
@@ -0,0 +1,501 @@
package com.eactive.eai.common.inflow;
import java.time.Duration;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
import io.github.bucket4j.local.LocalBucketBuilder;
public abstract class AbstractInflowControlManager implements Lifecycle, Bucket {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static final String QUOTA_TIMEUNIT_SECOND = "SEC";
public static final String QUOTA_TIMEUNIT_MINUTE = "MIN";
public static final String QUOTA_TIMEUNIT_HOUR = "HOU";
public static final String QUOTA_TIMEUNIT_DAY = "DAY";
public static final String QUOTA_TIMEUNIT_MONTH = "MON";
private volatile Map<String, CustomBucket> adapterBucketList = new ConcurrentHashMap<>();
private volatile Map<String, CustomBucket> interfaceBucketList = new ConcurrentHashMap<>();
private volatile Map<String, CustomGroupBucket> groupBucketList = new ConcurrentHashMap<>();
private volatile Map<String, String> interfaceToGroupMap = new ConcurrentHashMap<>();
private volatile Map<String, InflowGroupVO> groupVoMap = new ConcurrentHashMap<>();
private boolean started;
private LifecycleSupport lifecycle = new LifecycleSupport(this);
@Autowired
protected InflowControlDAO inflowControlDAO;
public void start() throws LifecycleException {
if (started)
throw new LifecycleException("RECEAICMM201");
lifecycle.fireLifecycleEvent(STARTING_EVENT, this);
try {
init();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAICMM202"));
}
started = true;
lifecycle.fireLifecycleEvent(STARTED_EVENT, this);
}
private void init() throws Exception {
initAdapter();
initInterface();
initGroup();
}
private void initAdapter() throws Exception {
Map<String, CustomBucket> newMap = new HashMap<>();
List<InflowTargetVO> inflowAdapterVoList = inflowControlDAO.getInflowAdapterList();
for (InflowTargetVO inflowVo : inflowAdapterVoList) {
if (AbstractInflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
newMap.put(inflowVo.getName(), bucket);
}
}
}
this.adapterBucketList = newMap;
}
private void initInterface() throws Exception {
Map<String, CustomBucket> newMap = new HashMap<>();
List<InflowTargetVO> inflowInterfaceVoList = inflowControlDAO.getInflowInterfaceList();
for (InflowTargetVO inflowVo : inflowInterfaceVoList) {
if (AbstractInflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
newMap.put(inflowVo.getName(), bucket);
}
}
}
this.interfaceBucketList = newMap;
}
private void initGroup() throws Exception {
Map<String, CustomGroupBucket> newGroupBucketList = new HashMap<>();
Map<String, String> newInterfaceToGroupMap = new HashMap<>();
Map<String, InflowGroupVO> newGroupVoMap = new HashMap<>();
List<InflowGroupVO> inflowGroupVoList = inflowControlDAO.getInflowGroupList();
for (InflowGroupVO groupVo : inflowGroupVoList) {
if (isInflowGroupTarget(groupVo)) {
CustomGroupBucket bucket = makeGroupBucket(groupVo);
if (bucket != null) {
newGroupBucketList.put(groupVo.getGroupId(), bucket);
newGroupVoMap.put(groupVo.getGroupId(), groupVo);
for (String interfaceId : groupVo.getInterfaceList()) {
newInterfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
}
}
}
}
if (logger.isInfo()) {
logger.info("InflowControlManager] initGroup completed. groups=" + newGroupBucketList.size()
+ ", mappings=" + newInterfaceToGroupMap.size());
}
this.groupBucketList = newGroupBucketList;
this.interfaceToGroupMap = newInterfaceToGroupMap;
this.groupVoMap = newGroupVoMap;
}
public synchronized void reloadAdapter() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all Started ...");
}
initAdapter();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all finished ...");
}
}
public synchronized void reloadInterface() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all Started ...");
}
initInterface();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all finished ...");
}
}
public synchronized void reloadAdapter(String adapter) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload Started ...");
}
Map<String, InflowTargetVO> map = inflowControlDAO.getInflowTargetByAdater(adapter);
if (map != null) {
Iterator<String> it = map.keySet().iterator();
String key = "";
while (it.hasNext()) {
key = it.next();
InflowTargetVO inflowTarget = map.get(key);
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowTarget);
if (bucket != null) {
adapterBucketList.put(inflowTarget.getName(), bucket);
} else {
adapterBucketList.remove(inflowTarget.getName());
}
} else {
adapterBucketList.remove(inflowTarget.getName());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload finished ...");
}
}
public synchronized void reloadInterface(String inter) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload Started ...");
}
Map<String, InflowTargetVO> map = inflowControlDAO.getInflowTargetByInterface(inter);
if (map != null) {
Iterator<String> it = map.keySet().iterator();
String key = "";
while (it.hasNext()) {
key = it.next();
InflowTargetVO inflowTarget = map.get(key);
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowTarget);
if (bucket != null) {
interfaceBucketList.put(inflowTarget.getName(), bucket);
} else {
interfaceBucketList.remove(inflowTarget.getName());
}
} else {
interfaceBucketList.remove(inflowTarget.getName());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload finished ...");
}
}
public synchronized void reloadGroup() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup all Started ...");
}
initGroup();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup all finished ...");
}
}
public synchronized void reloadGroup(String groupId) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup Started ... groupId=" + groupId);
}
Map<String, InflowGroupVO> map = inflowControlDAO.getInflowTargetByGroup(groupId);
if (map != null) {
Iterator<Map.Entry<String, String>> mappingIt = interfaceToGroupMap.entrySet().iterator();
while (mappingIt.hasNext()) {
Map.Entry<String, String> entry = mappingIt.next();
if (groupId.equals(entry.getValue())) {
mappingIt.remove();
}
}
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
String key = it.next();
InflowGroupVO groupVo = map.get(key);
if (isInflowGroupTarget(groupVo)) {
CustomGroupBucket bucket = makeGroupBucket(groupVo);
if (bucket != null) {
groupBucketList.put(groupVo.getGroupId(), bucket);
groupVoMap.put(groupVo.getGroupId(), groupVo);
for (String interfaceId : groupVo.getInterfaceList()) {
interfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
}
} else {
groupBucketList.remove(groupVo.getGroupId());
groupVoMap.remove(groupVo.getGroupId());
}
} else {
groupBucketList.remove(groupVo.getGroupId());
groupVoMap.remove(groupVo.getGroupId());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup finished ...");
}
}
public void stop() throws LifecycleException {
if (!started)
throw new LifecycleException("RECEAICMM203");
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
adapterBucketList = new ConcurrentHashMap<>();
interfaceBucketList = new ConcurrentHashMap<>();
groupBucketList = new ConcurrentHashMap<>();
interfaceToGroupMap = new ConcurrentHashMap<>();
groupVoMap = new ConcurrentHashMap<>();
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
public void addLifecycleListener(LifecycleListener listener) {
lifecycle.addLifecycleListener(listener);
}
public LifecycleListener[] findLifecycleListeners() {
return lifecycle.findLifecycleListeners();
}
public void removeLifecycleListener(LifecycleListener listener) {
lifecycle.removeLifecycleListener(listener);
}
public boolean isStarted() {
return this.started;
}
public String[] getAdapterAllKeys() {
Iterator<String> it = this.adapterBucketList.keySet().iterator();
String[] svcCd = new String[this.adapterBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
svcCd[i] = it.next();
}
Arrays.sort(svcCd);
return svcCd;
}
public String[] getInterfaceAllKeys() {
Iterator<String> it = this.interfaceBucketList.keySet().iterator();
String[] svcCd = new String[this.interfaceBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
svcCd[i] = it.next();
}
Arrays.sort(svcCd);
return svcCd;
}
public synchronized void removeAdapter(String adapter) {
adapterBucketList.remove(adapter);
}
public synchronized void removeInterface(String inter) {
interfaceBucketList.remove(inter);
}
public synchronized void removeGroup(String groupId) {
groupBucketList.remove(groupId);
groupVoMap.remove(groupId);
Iterator<Map.Entry<String, String>> it = interfaceToGroupMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, String> entry = it.next();
if (groupId.equals(entry.getValue())) {
it.remove();
}
}
}
public String[] getGroupAllKeys() {
Iterator<String> it = this.groupBucketList.keySet().iterator();
String[] groupIds = new String[this.groupBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
groupIds[i] = it.next();
}
Arrays.sort(groupIds);
return groupIds;
}
public CustomGroupBucket getGroupBucket(String groupId) {
return groupBucketList.get(groupId);
}
private CustomBucket makeBucketUsingInflowVO(InflowTargetVO inflowTarget) {
if (AbstractInflowControlManager.isInflowTarget(inflowTarget)) {
LocalBucketBuilder builder = Bucket4j.builder();
if (inflowTarget.getThresholdPerSecond() > 0) {
builder.addLimit(Bandwidth.classic(inflowTarget.getThresholdPerSecond(),
Refill.greedy(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1))));
}
if (inflowTarget.getThreshold() > 0 && StringUtils.isNotBlank(inflowTarget.getThresholdTimeUnit())) {
builder.addLimit(Bandwidth.classic(inflowTarget.getThreshold(),
Refill.intervally(inflowTarget.getThreshold(),
AbstractInflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit()))));
}
return new CustomBucket(builder.build(), inflowTarget);
}
return null;
}
private CustomGroupBucket makeGroupBucket(InflowGroupVO groupVo) {
if (!isInflowGroupTarget(groupVo)) {
return null;
}
LocalBucket perSecondBucket = null;
LocalBucket thresholdBucket = null;
if (groupVo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.classic(groupVo.getThresholdPerSecond(),
Refill.greedy(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (groupVo.getThreshold() > 0 && StringUtils.isNotBlank(groupVo.getThresholdTimeUnit())) {
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.classic(groupVo.getThreshold(),
Refill.intervally(groupVo.getThreshold(),
AbstractInflowControlManager.getPeriod(groupVo.getThresholdTimeUnit()))))
.build();
}
return new CustomGroupBucket(perSecondBucket, thresholdBucket, groupVo);
}
@Override
public boolean isAdapterPass(String adapter) {
CustomBucket b = adapterBucketList.get(adapter);
if (b == null)
return true;
return b.getLocalBucket().tryConsume(1);
}
@Override
public boolean isInterfacePass(String inter) {
CustomBucket b = interfaceBucketList.get(inter);
if (b == null)
return true;
return b.getLocalBucket().tryConsume(1);
}
@Override
public InflowTargetVO getAdapterInflowThreashold(String adapter) {
CustomBucket b = adapterBucketList.get(adapter);
if (b == null)
return null;
return b.getInflowTargetVo();
}
@Override
public InflowTargetVO getInterfaceInflowThreashold(String inter) {
CustomBucket b = interfaceBucketList.get(inter);
if (b == null)
return null;
return b.getInflowTargetVo();
}
@Override
public InflowTargetVO getInflowThreashold(String inter) {
return null;
}
public InflowTargetVO getAdapterInflow(String adapter) {
return getAdapterInflowThreashold(adapter);
}
public InflowTargetVO getInterfaceInflow(String inter) {
return getInterfaceInflowThreashold(inter);
}
@Override
public String isGroupPass(String groupId) {
CustomGroupBucket b = groupBucketList.get(groupId);
if (b == null)
return null;
return b.tryConsume();
}
@Override
public InflowGroupVO getGroupInflowThreshold(String groupId) {
return groupVoMap.get(groupId);
}
@Override
public String getGroupIdByInterface(String interfaceId) {
return interfaceToGroupMap.get(interfaceId);
}
public InflowGroupVO getGroupInflow(String groupId) {
return getGroupInflowThreshold(groupId);
}
public static boolean isInflowTarget(InflowTargetVO inflowTarget) {
if (inflowTarget == null || !inflowTarget.isActivate()) {
return false;
}
return inflowTarget.getThresholdPerSecond() > 0 || (inflowTarget.getThreshold() > 0 && StringUtils
.equalsAnyIgnoreCase(inflowTarget.getThresholdTimeUnit(), QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_SECOND,
QUOTA_TIMEUNIT_MINUTE, QUOTA_TIMEUNIT_HOUR, QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_MONTH));
}
public static boolean isInflowGroupTarget(InflowGroupVO groupVo) {
if (groupVo == null || !groupVo.isActivate()) {
return false;
}
return groupVo.getThresholdPerSecond() > 0 || (groupVo.getThreshold() > 0 && StringUtils
.equalsAnyIgnoreCase(groupVo.getThresholdTimeUnit(), QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_SECOND,
QUOTA_TIMEUNIT_MINUTE, QUOTA_TIMEUNIT_HOUR, QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_MONTH));
}
public static Duration getPeriod(String timeUnit) {
if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_SECOND)) {
return Duration.ofSeconds(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_MINUTE)) {
return Duration.ofMinutes(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_HOUR)) {
return Duration.ofHours(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_DAY)) {
return Duration.ofDays(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_MONTH)) {
Calendar calendar = Calendar.getInstance();
return Duration.ofDays(calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
} else {
return null;
}
}
}
@@ -41,20 +41,36 @@ public class InflowControlDAO extends BaseDAO {
@Autowired
private InflowControlGroupMappingLoader inflowControlGroupMappingLoader;
// -------------------------------------------------------------------------
// Enum 기반 범용 메서드 — type 코드를 직접 노출하지 않음
// -------------------------------------------------------------------------
public List<InflowTargetVO> getInflowList(InflowType type) throws DAOException {
return getInflowList(type.code());
}
public Map<String, InflowTargetVO> getInflowMap(InflowType type, String name) throws DAOException {
return getInflowMap(type.code(), name);
}
// -------------------------------------------------------------------------
// 기존 명명 메서드 — enum 기반 메서드로 위임
// -------------------------------------------------------------------------
public List<InflowTargetVO> getInflowAdapterList() throws DAOException {
return getInflowList("01");
return getInflowList(InflowType.ADAPTER);
}
public List<InflowTargetVO> getInflowInterfaceList() throws DAOException {
return getInflowList("02");
return getInflowList(InflowType.INTERFACE);
}
public Map<String, InflowTargetVO> getInflowTargetByAdater(String name) throws DAOException {
return getInflowMap("01", name);
return getInflowMap(InflowType.ADAPTER, name);
}
public Map<String, InflowTargetVO> getInflowTargetByInterface(String name) throws DAOException {
return getInflowMap("02", name);
return getInflowMap(InflowType.INTERFACE, name);
}
private List<InflowTargetVO> getInflowList(String type) throws DAOException {
@@ -1,54 +1,11 @@
package com.eactive.eai.common.inflow;
import java.time.Duration;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.message.service.InterfaceMapper;
import com.ext.eai.common.stdmessage.STDMessageKeys;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
import io.github.bucket4j.local.LocalBucketBuilder;
@Component
public class InflowControlManager implements Lifecycle, Bucket {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static final String QUOTA_TIMEUNIT_SECOND = "SEC";
public static final String QUOTA_TIMEUNIT_MINUTE = "MIN";
public static final String QUOTA_TIMEUNIT_HOUR = "HOU";
public static final String QUOTA_TIMEUNIT_DAY = "DAY";
public static final String QUOTA_TIMEUNIT_MONTH = "MON";
private Map<String, CustomBucket> adapterBucketList = new HashMap<>();
private Map<String, CustomBucket> interfaceBucketList = new HashMap<>();
private Map<String, CustomGroupBucket> groupBucketList = new HashMap<>();
private Map<String, String> interfaceToGroupMap = new HashMap<>();
private Map<String, InflowGroupVO> groupVoMap = new HashMap<>();
private boolean started;
private LifecycleSupport lifecycle = new LifecycleSupport(this);
@Autowired
private InflowControlDAO inflowControlDAO;
public class InflowControlManager extends AbstractInflowControlManager {
public static synchronized InflowControlManager getInstance() {
return ApplicationContextProvider.getContext().getBean(InflowControlManager.class);
@@ -57,475 +14,4 @@ public class InflowControlManager implements Lifecycle, Bucket {
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(InflowControlManager.class);
}
/**
* 유량제어 대상인 거래인지 체크한다.(사이트에 맞게 구성)
*
* @param eaiServerManager
* @param eaiMessage
* @return
*/
public static Boolean isTargetOfInflowControl(EAIServerManager eaiServerManager, EAIMessage eaiMessage) {
InterfaceMapper mapper = eaiMessage.getMapper();
String returnType = mapper.getSendRecvDivision(eaiMessage.getStandardMessage()); // S|R
if (STDMessageKeys.SEND_RECV_CD_SEND.equals(returnType)) {
return Boolean.valueOf(true);
}
return Boolean.valueOf(false);
}
public void start() throws LifecycleException {
if (started)
throw new LifecycleException("RECEAICMM201");
lifecycle.fireLifecycleEvent(STARTING_EVENT, this);
try {
init();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAICMM202"));
}
started = true;
lifecycle.fireLifecycleEvent(STARTED_EVENT, this);
}
private void init() throws Exception {
initAdapter();
initInterface();
initGroup();
}
private void initAdapter() throws Exception {
adapterBucketList = new HashMap<>();
List<InflowTargetVO> inflowAdapterVoList = inflowControlDAO.getInflowAdapterList();
for (InflowTargetVO inflowVo : inflowAdapterVoList) {
if (InflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
adapterBucketList.put(inflowVo.getName(), bucket);
}
}
}
}
private void initInterface() throws Exception {
interfaceBucketList = new HashMap<>();
List<InflowTargetVO> inflowInterfaceVoList = inflowControlDAO.getInflowInterfaceList();
for (InflowTargetVO inflowVo : inflowInterfaceVoList) {
if (InflowControlManager.isInflowTarget(inflowVo)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowVo);
if (bucket != null) {
interfaceBucketList.put(inflowVo.getName(), bucket);
}
}
}
}
private void initGroup() throws Exception {
groupBucketList = new HashMap<>();
interfaceToGroupMap = new HashMap<>();
groupVoMap = new HashMap<>();
List<InflowGroupVO> inflowGroupVoList = inflowControlDAO.getInflowGroupList();
for (InflowGroupVO groupVo : inflowGroupVoList) {
if (isInflowGroupTarget(groupVo)) {
CustomGroupBucket bucket = makeGroupBucket(groupVo);
if (bucket != null) {
groupBucketList.put(groupVo.getGroupId(), bucket);
groupVoMap.put(groupVo.getGroupId(), groupVo);
// 인터페이스 → 그룹 매핑 등록
for (String interfaceId : groupVo.getInterfaceList()) {
interfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
}
}
}
}
if (logger.isInfo()) {
logger.info("InflowControlManager] initGroup completed. groups=" + groupBucketList.size()
+ ", mappings=" + interfaceToGroupMap.size());
}
}
public synchronized void reloadAdapter() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all Started ...");
}
initAdapter();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all finished ...");
}
}
public synchronized void reloadInterface() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all Started ...");
}
initInterface();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload all finished ...");
}
}
public synchronized void reloadAdapter(String adapter) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload Started ...");
}
Map<String, InflowTargetVO> map = inflowControlDAO.getInflowTargetByAdater(adapter);
if (map != null) {
Iterator<String> it = map.keySet().iterator();
String key = "";
while (it.hasNext()) {
key = it.next();
InflowTargetVO inflowTarget = map.get(key);
if (InflowControlManager.isInflowTarget(inflowTarget)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowTarget);
if (bucket != null) {
adapterBucketList.put(inflowTarget.getName(), bucket);
} else {
adapterBucketList.remove(inflowTarget.getName());
}
} else {
adapterBucketList.remove(inflowTarget.getName());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload finished ...");
}
}
public synchronized void reloadInterface(String inter) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload Started ...");
}
Map<String, InflowTargetVO> map = inflowControlDAO.getInflowTargetByInterface(inter);
if (map != null) {
Iterator<String> it = map.keySet().iterator();
String key = "";
while (it.hasNext()) {
key = it.next();
InflowTargetVO inflowTarget = map.get(key);
if (InflowControlManager.isInflowTarget(inflowTarget)) {
CustomBucket bucket = makeBucketUsingInflowVO(inflowTarget);
if (bucket != null) {
interfaceBucketList.put(inflowTarget.getName(), bucket);
} else {
interfaceBucketList.remove(inflowTarget.getName());
}
} else {
interfaceBucketList.remove(inflowTarget.getName());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reload finished ...");
}
}
public synchronized void reloadGroup() throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup all Started ...");
}
initGroup();
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup all finished ...");
}
}
public synchronized void reloadGroup(String groupId) throws Exception {
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup Started ... groupId=" + groupId);
}
Map<String, InflowGroupVO> map = inflowControlDAO.getInflowTargetByGroup(groupId);
if (map != null) {
// 기존 그룹에 속한 인터페이스 매핑 제거
Iterator<Map.Entry<String, String>> mappingIt = interfaceToGroupMap.entrySet().iterator();
while (mappingIt.hasNext()) {
Map.Entry<String, String> entry = mappingIt.next();
if (groupId.equals(entry.getValue())) {
mappingIt.remove();
}
}
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
String key = it.next();
InflowGroupVO groupVo = map.get(key);
if (isInflowGroupTarget(groupVo)) {
CustomGroupBucket bucket = makeGroupBucket(groupVo);
if (bucket != null) {
groupBucketList.put(groupVo.getGroupId(), bucket);
groupVoMap.put(groupVo.getGroupId(), groupVo);
// 인터페이스 → 그룹 매핑 재등록
for (String interfaceId : groupVo.getInterfaceList()) {
interfaceToGroupMap.put(interfaceId, groupVo.getGroupId());
}
} else {
groupBucketList.remove(groupVo.getGroupId());
groupVoMap.remove(groupVo.getGroupId());
}
} else {
groupBucketList.remove(groupVo.getGroupId());
groupVoMap.remove(groupVo.getGroupId());
}
}
}
if (logger.isWarn()) {
logger.warn("InflowControlManager] reloadGroup finished ...");
}
}
public void stop() throws LifecycleException {
// Validate and update our current component state
if (!started)
throw new LifecycleException("RECEAICMM203");
// Notify our interested LifecycleListeners
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
adapterBucketList.clear();
interfaceBucketList.clear();
groupBucketList.clear();
interfaceToGroupMap.clear();
groupVoMap.clear();
started = false;
// Notify our interested LifecycleListeners
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
public void addLifecycleListener(LifecycleListener listener) {
lifecycle.addLifecycleListener(listener);
}
public LifecycleListener[] findLifecycleListeners() {
return lifecycle.findLifecycleListeners();
}
public void removeLifecycleListener(LifecycleListener listener) {
lifecycle.removeLifecycleListener(listener);
}
public boolean isStarted() {
return this.started;
}
public String[] getAdapterAllKeys() {
Iterator<String> it = this.adapterBucketList.keySet().iterator();
String[] svcCd = new String[this.adapterBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
svcCd[i] = it.next();
}
Arrays.sort(svcCd);
return svcCd;
}
public String[] getInterfaceAllKeys() {
Iterator<String> it = this.interfaceBucketList.keySet().iterator();
String[] svcCd = new String[this.interfaceBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
svcCd[i] = it.next();
}
Arrays.sort(svcCd);
return svcCd;
}
public void removeAdapter(String adapter) {
adapterBucketList.remove(adapter);
}
public void removeInterface(String inter) {
interfaceBucketList.remove(inter);
}
public void removeGroup(String groupId) {
groupBucketList.remove(groupId);
groupVoMap.remove(groupId);
// 해당 그룹에 속한 인터페이스 매핑도 제거
Iterator<Map.Entry<String, String>> it = interfaceToGroupMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, String> entry = it.next();
if (groupId.equals(entry.getValue())) {
it.remove();
}
}
}
public String[] getGroupAllKeys() {
Iterator<String> it = this.groupBucketList.keySet().iterator();
String[] groupIds = new String[this.groupBucketList.size()];
for (int i = 0; it.hasNext(); i++) {
groupIds[i] = it.next();
}
Arrays.sort(groupIds);
return groupIds;
}
private CustomBucket makeBucketUsingInflowVO(InflowTargetVO inflowTarget) {
if (InflowControlManager.isInflowTarget(inflowTarget)) {
LocalBucketBuilder builder = Bucket4j.builder();
if (inflowTarget.getThresholdPerSecond() > 0) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThresholdPerSecond(), Duration.ofSeconds(1)));
}
if (inflowTarget.getThreshold() > 0 && StringUtils.isNotBlank(inflowTarget.getThresholdTimeUnit())) {
builder.addLimit(Bandwidth.simple(inflowTarget.getThreshold(),
InflowControlManager.getPeriod(inflowTarget.getThresholdTimeUnit())));
}
return new CustomBucket(builder.build(), inflowTarget);
}
return null;
}
/**
* 그룹용 버킷 생성 - 초당/추가 임계치를 별도 버킷으로 분리
*/
private CustomGroupBucket makeGroupBucket(InflowGroupVO groupVo) {
if (!isInflowGroupTarget(groupVo)) {
return null;
}
LocalBucket perSecondBucket = null;
LocalBucket thresholdBucket = null;
// 초당 임계치 버킷 (별도)
if (groupVo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThresholdPerSecond(), Duration.ofSeconds(1)))
.build();
}
// 추가 임계치 버킷 (별도)
if (groupVo.getThreshold() > 0 && StringUtils.isNotBlank(groupVo.getThresholdTimeUnit())) {
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.simple(groupVo.getThreshold(),
InflowControlManager.getPeriod(groupVo.getThresholdTimeUnit())))
.build();
}
return new CustomGroupBucket(perSecondBucket, thresholdBucket, groupVo);
}
@Override
public boolean isAdapterPass(String adapter) {
CustomBucket b = adapterBucketList.get(adapter);
if (b == null)
return true;
return b.getLocalBucket().tryConsume(1);
}
@Override
public boolean isInterfacePass(String inter) {
CustomBucket b = interfaceBucketList.get(inter);
if (b == null)
return true;
return b.getLocalBucket().tryConsume(1);
}
@Override
public InflowTargetVO getAdapterInflowThreashold(String adapter) {
CustomBucket b = adapterBucketList.get(adapter);
if (b == null)
return null;
return b.getInflowTargetVo();
}
@Override
public InflowTargetVO getInterfaceInflowThreashold(String inter) {
CustomBucket b = interfaceBucketList.get(inter);
if (b == null)
return null;
return b.getInflowTargetVo();
}
@Override
public InflowTargetVO getInflowThreashold(String inter) {
return null;
}
public InflowTargetVO getAdapterInflow(String adapter) {
return getAdapterInflowThreashold(adapter);
}
public InflowTargetVO getInterfaceInflow(String inter) {
return getInterfaceInflowThreashold(inter);
}
@Override
public String isGroupPass(String groupId) {
CustomGroupBucket b = groupBucketList.get(groupId);
if (b == null)
return null;
return b.tryConsume();
}
@Override
public InflowGroupVO getGroupInflowThreshold(String groupId) {
return groupVoMap.get(groupId);
}
@Override
public String getGroupIdByInterface(String interfaceId) {
return interfaceToGroupMap.get(interfaceId);
}
public InflowGroupVO getGroupInflow(String groupId) {
return getGroupInflowThreshold(groupId);
}
public static boolean isInflowTarget(InflowTargetVO inflowTarget) {
if (inflowTarget == null || !inflowTarget.isActivate()) {
return false;
}
return inflowTarget.getThresholdPerSecond() > 0 || (inflowTarget.getThreshold() > 0 && StringUtils
.equalsAnyIgnoreCase(inflowTarget.getThresholdTimeUnit(), QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_SECOND,
QUOTA_TIMEUNIT_MINUTE, QUOTA_TIMEUNIT_HOUR, QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_MONTH));
}
public static boolean isInflowGroupTarget(InflowGroupVO groupVo) {
if (groupVo == null || !groupVo.isActivate()) {
return false;
}
return groupVo.getThresholdPerSecond() > 0 || (groupVo.getThreshold() > 0 && StringUtils
.equalsAnyIgnoreCase(groupVo.getThresholdTimeUnit(), QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_SECOND,
QUOTA_TIMEUNIT_MINUTE, QUOTA_TIMEUNIT_HOUR, QUOTA_TIMEUNIT_DAY, QUOTA_TIMEUNIT_MONTH));
}
public static Duration getPeriod(String timeUnit) {
if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_SECOND)) {
return Duration.ofSeconds(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_MINUTE)) {
return Duration.ofMinutes(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_HOUR)) {
return Duration.ofHours(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_DAY)) {
return Duration.ofDays(1);
} else if (StringUtils.equalsIgnoreCase(timeUnit, QUOTA_TIMEUNIT_MONTH)) {
Calendar calendar = Calendar.getInstance();
return Duration.ofDays(calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
} else {
return null;
}
}
}
@@ -8,7 +8,10 @@ import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.message.service.InterfaceMapper;
import com.ext.eai.common.stdmessage.STDMessageKeys;
public class InflowControlUtil {
private InflowControlUtil() {
@@ -16,28 +19,70 @@ public class InflowControlUtil {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static volatile AbstractInflowControlManager cachedInflowControlManager;
private static volatile Bucket cachedBucket;
/**
* INFLOW.properties의 inflow.control.bucket.className 설정에 따라
* 활성화된 InflowControlManager 구현체를 반환한다.
*
* getBean(InflowControlManager.class) 대신 설정된 구체 타입으로 조회하므로
* InflowControlManager와 DualInflowControlManager가 모두 Bean으로 등록된
* 환경에서도 NoUniqueBeanDefinitionException 없이 올바른 인스턴스를 반환한다.
*
* 최초 1회만 조회하고 이후에는 캐시된 인스턴스를 반환한다.
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
public static Bucket getBucket() {
Properties inFlowProp = PropManager.getInstance().getProperties("INFLOW");
String className = inFlowProp.getProperty("inflow.control.bucket.className");
if (StringUtils.isBlank(className)
|| StringUtils.equals(className, "com.eactive.eai.common.inflow.InflowControlManager")) {
return InflowControlManager.getBucket();
} else {
try {
Class clazz = Class.forName(className);
Method method = clazz.getMethod("getBucket", (Class<?>[]) null);
return (Bucket) method.invoke(null, (Object[]) null);
} catch (Exception e) {
logger.error("Method call error class= {}, method= getBucket", className);
logger.error(e.getMessage());
public static AbstractInflowControlManager getInflowControlManager() {
if (cachedInflowControlManager == null) {
synchronized (InflowControlUtil.class) {
if (cachedInflowControlManager == null) {
Properties inFlowProp = PropManager.getInstance().getProperties("INFLOW");
String className = inFlowProp.getProperty("inflow.control.bucket.className");
if (StringUtils.isBlank(className)
|| StringUtils.equals(className, "com.eactive.eai.common.inflow.InflowControlManager")) {
cachedInflowControlManager = InflowControlManager.getInstance();
} else {
try {
Class clazz = Class.forName(className);
cachedInflowControlManager = (AbstractInflowControlManager) ApplicationContextProvider.getContext().getBean(clazz);
} catch (Exception e) {
logger.error("getInflowControlManager error class= {}", className);
logger.error(e.getMessage());
}
}
}
}
}
return null;
return cachedInflowControlManager;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
public static Bucket getBucket() {
if (cachedBucket == null) {
synchronized (InflowControlUtil.class) {
if (cachedBucket == null) {
Properties inFlowProp = PropManager.getInstance().getProperties("INFLOW");
String className = inFlowProp.getProperty("inflow.control.bucket.className");
if (StringUtils.isBlank(className)
|| StringUtils.equals(className, "com.eactive.eai.common.inflow.InflowControlManager")) {
cachedBucket = InflowControlManager.getBucket();
} else {
try {
Class clazz = Class.forName(className);
Method method = clazz.getMethod("getBucket", (Class<?>[]) null);
cachedBucket = (Bucket) method.invoke(null, (Object[]) null);
} catch (Exception e) {
logger.error("Method call error class= {}, method= getBucket", className);
logger.error(e.getMessage());
}
}
}
}
}
return cachedBucket;
}
public static boolean isTargetOfInflowControl(EAIServerManager eaiServerManager, EAIMessage eaiMessage) {
Properties inFlowProp = PropManager.getInstance().getProperties("INFLOW");
String controlInflow = inFlowProp.getProperty("inflow.control.yn", "N");
@@ -45,21 +90,18 @@ public class InflowControlUtil {
if (!"Y".equalsIgnoreCase(controlInflow)) {
return false;
}
try {
InterfaceMapper mapper = eaiMessage.getMapper();
String returnType = mapper.getSendRecvDivision(eaiMessage.getStandardMessage()); // S|R
String className = inFlowProp.getProperty("inflow.control.bucket.className");
if (StringUtils.isBlank(className)
|| StringUtils.equals(className, "com.eactive.eai.common.inflow.InflowControlManager")) {
return InflowControlManager.isTargetOfInflowControl(eaiServerManager, eaiMessage).booleanValue();
} else {
try {
Class clazz = Class.forName(className);
Method method = clazz.getMethod("isTargetOfInflowControl", EAIServerManager.class, EAIMessage.class);
Boolean returnBoolean = (Boolean) method.invoke(null, eaiServerManager, eaiMessage);
return returnBoolean.booleanValue();
} catch (Exception e) {
logger.error("Method call error class= {}, method= isTargetOfInflowControl", className);
logger.error(e.getMessage());
if (STDMessageKeys.SEND_RECV_CD_SEND.equals(returnType)) {
return Boolean.valueOf(true);
}
return Boolean.valueOf(false);
} catch (Exception e) {
logger.error("isTargetOfInflowControl call error", e.getMessage());
}
return false;
@@ -0,0 +1,23 @@
package com.eactive.eai.common.inflow;
/**
* 유량제어 대상 유형.
* {@link InflowControlDAO}의 type 컬럼 값을 enum으로 관리하여
* 호출부에 코드 문자열이 노출되지 않도록 한다.
*/
public enum InflowType {
ADAPTER("01"),
INTERFACE("02"),
CLIENT("03");
private final String code;
InflowType(String code) {
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,22 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* DualBucket에 클라이언트 유량제어 메서드를 추가한 인터페이스.
* {@link ClientDualInflowControlManager}가 구현한다.
*/
public interface ClientDualBucket extends DualBucket {
/**
* 클라이언트 유량제어 체크 — 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isClientPassDetail(String clientId);
/**
* 클라이언트 유량제어 설정 조회 — 에러 메시지 생성용.
* @return 등록된 설정이 없으면 null
*/
InflowTargetVO getClientInflowThreshold(String clientId);
}
@@ -0,0 +1,112 @@
package com.eactive.eai.common.inflow.dual;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.InflowType;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
/**
* 어댑터/인터페이스 이중 버킷(DualInflowControlManager)에 클라이언트 유량제어를 추가한 관리자.
*
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
@Component
public class ClientDualInflowControlManager extends DualInflowControlManager implements ClientDualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(ClientDualInflowControlManager.class);
}
private volatile Map<String, DualCustomBucket> clientBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> clientMetricsMap = new ConcurrentHashMap<>();
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
super.start();
try {
initClients();
} catch (Exception e) {
throw new LifecycleException("ClientDualInflowControlManager init failed: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// 초기화 / 리로드
// -------------------------------------------------------------------------
private void initClients() throws Exception {
this.clientBucketMap = loadBucketMap(InflowType.CLIENT);
if (logger.isInfo()) logger.info("ClientDualInflowControlManager] initClients: " + clientBucketMap.size() + " buckets");
}
public synchronized void reloadClient() throws Exception {
initClients();
clientMetricsMap.clear();
}
public synchronized void reloadClient(String clientId) throws Exception {
reloadBucketMap(clientBucketMap, InflowType.CLIENT, clientId);
TargetMetrics m = clientMetricsMap.get(clientId);
if (m != null) m.reset();
}
public synchronized void removeClient(String clientId) {
clientBucketMap.remove(clientId);
clientMetricsMap.remove(clientId);
}
// -------------------------------------------------------------------------
// 클라이언트 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getClientMetrics(String clientId) { return clientMetricsMap.get(clientId); }
public Map<String, TargetMetrics> getAllClientMetrics() { return Collections.unmodifiableMap(clientMetricsMap); }
public void resetClientMetrics(String clientId) { TargetMetrics m = clientMetricsMap.get(clientId); if (m != null) m.reset(); }
public void resetAllClientMetrics() { clientMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// ClientDualBucket — 클라이언트 유량제어
// -------------------------------------------------------------------------
@Override
public String isClientPassDetail(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(clientMetricsMap, clientId, result);
return result;
}
@Override
public InflowTargetVO getClientInflowThreshold(String clientId) {
DualCustomBucket b = clientBucketMap.get(clientId);
return (b == null) ? null : b.getInflowTargetVo();
}
// -------------------------------------------------------------------------
// 모니터링용 접근자
// -------------------------------------------------------------------------
public DualCustomBucket getClientBucket(String clientId) {
return clientBucketMap.get(clientId);
}
public Map<String, DualCustomBucket> getClientBucketMap() {
return clientBucketMap;
}
}
@@ -0,0 +1,25 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.Bucket;
/**
* Bucket 인터페이스 확장.
* 기존 isAdapterPass/isInterfacePass(boolean)에 더해
* 어느 버킷(PER_SECOND/THRESHOLD)에서 차단됐는지 반환하는 메서드 추가.
*
* <p>클라이언트 유량제어는 {@link ClientDualBucket}으로 분리됨.
*/
public interface DualBucket extends Bucket {
/**
* 어댑터 유량제어 체크 — 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isAdapterPassDetail(String adapter);
/**
* 인터페이스 유량제어 체크 — 차단 원인 포함.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
String isInterfacePassDetail(String inter);
}
@@ -0,0 +1,58 @@
package com.eactive.eai.common.inflow.dual;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스 유량제어 버킷.
* 기존 CustomBucket(단일 버킷)과 달리 perSecond/threshold를 별도 버킷으로 분리하여
* 어느 한도를 초과했는지 구분 가능 (CustomGroupBucket과 동일한 이중 구조).
*/
public class DualCustomBucket {
public static final String RESULT_PASS = null;
public static final String RESULT_BLOCKED_PER_SECOND = "PER_SECOND";
public static final String RESULT_BLOCKED_THRESHOLD = "THRESHOLD";
private final LocalBucket perSecondBucket;
private final LocalBucket thresholdBucket;
private final InflowTargetVO inflowTargetVo;
public DualCustomBucket(LocalBucket perSecondBucket, LocalBucket thresholdBucket, InflowTargetVO inflowTargetVo) {
this.perSecondBucket = perSecondBucket;
this.thresholdBucket = thresholdBucket;
this.inflowTargetVo = inflowTargetVo;
}
/**
* 두 버킷을 순서대로 소비 시도.
* @return null이면 통과, "PER_SECOND" 또는 "THRESHOLD"이면 해당 버킷에서 차단
*/
public String tryConsume() {
boolean perSecondPass = (perSecondBucket == null) || perSecondBucket.tryConsume(1);
if (!perSecondPass) {
return RESULT_BLOCKED_PER_SECOND;
}
boolean thresholdPass = (thresholdBucket == null) || thresholdBucket.tryConsume(1);
if (!thresholdPass) {
// perSecond 토큰은 이미 소비됨 — Token Bucket 특성상 롤백 불가
// 초당 버킷은 빠르게 리필되므로 실질적 영향 미미
return RESULT_BLOCKED_THRESHOLD;
}
return RESULT_PASS;
}
public long getPerSecondAvailableTokens() {
return (perSecondBucket != null) ? perSecondBucket.getAvailableTokens() : -1;
}
public long getThresholdAvailableTokens() {
return (thresholdBucket != null) ? thresholdBucket.getAvailableTokens() : -1;
}
public LocalBucket getPerSecondBucket() { return perSecondBucket; }
public LocalBucket getThresholdBucket() { return thresholdBucket; }
public InflowTargetVO getInflowTargetVo() { return inflowTargetVo; }
}
@@ -0,0 +1,342 @@
package com.eactive.eai.common.inflow.dual;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.inflow.AbstractInflowControlManager;
import com.eactive.eai.common.inflow.Bucket;
import com.eactive.eai.common.inflow.CustomGroupBucket;
import com.eactive.eai.common.inflow.InflowTargetVO;
import com.eactive.eai.common.inflow.InflowType;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.Refill;
import io.github.bucket4j.local.LocalBucket;
/**
* 어댑터/인터페이스 버킷을 이중 구조(perSecond + threshold)로 관리하는 유량제어 관리자.
*
* <p>클라이언트 유량제어는 {@link ClientDualInflowControlManager}로 분리됨.
*
* <p>적용 방법: INFLOW.properties에 아래 한 줄 추가.
* <pre>inflow.control.bucket.className=com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager</pre>
*/
public class DualInflowControlManager extends AbstractInflowControlManager implements DualBucket {
private static final Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
// InflowControlUtil이 리플렉션으로 호출하는 진입점
public static synchronized Bucket getBucket() {
return ApplicationContextProvider.getContext().getBean(DualInflowControlManager.class);
}
private volatile Map<String, DualCustomBucket> adapterBucketMap = new ConcurrentHashMap<>();
private volatile Map<String, DualCustomBucket> interfaceBucketMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> adapterMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> interfaceMetricsMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TargetMetrics> groupMetricsMap = new ConcurrentHashMap<>();
public static class TargetMetrics {
public final AtomicLong allowed = new AtomicLong();
public final AtomicLong rejectedPerSecond = new AtomicLong();
public final AtomicLong rejectedThreshold = new AtomicLong();
public volatile long lastResetTime = System.currentTimeMillis();
public void reset() {
allowed.set(0);
rejectedPerSecond.set(0);
rejectedThreshold.set(0);
lastResetTime = System.currentTimeMillis();
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
super.start();
try {
initAdapters();
initInterfaces();
} catch (Exception e) {
throw new LifecycleException("DualInflowControlManager init failed: " + e.getMessage());
}
}
// -------------------------------------------------------------------------
// 초기화 / 리로드
// -------------------------------------------------------------------------
private void initAdapters() throws Exception {
this.adapterBucketMap = loadBucketMap(InflowType.ADAPTER);
if (logger.isInfo()) logger.info("DualInflowControlManager] initAdapters: " + adapterBucketMap.size() + " buckets");
}
private void initInterfaces() throws Exception {
this.interfaceBucketMap = loadBucketMap(InflowType.INTERFACE);
if (logger.isInfo()) logger.info("DualInflowControlManager] initInterfaces: " + interfaceBucketMap.size() + " buckets");
}
protected Map<String, DualCustomBucket> loadBucketMap(InflowType type) throws Exception {
Map<String, DualCustomBucket> newMap = new HashMap<>();
for (InflowTargetVO vo : inflowControlDAO.getInflowList(type)) {
DualCustomBucket bucket = makeBucket(vo);
if (bucket != null) newMap.put(vo.getName(), bucket);
}
return newMap;
}
@Override
public synchronized void reloadAdapter() throws Exception {
initAdapters();
adapterMetricsMap.clear();
}
@Override
public synchronized void reloadAdapter(String adapter) throws Exception {
reloadBucketMap(adapterBucketMap, InflowType.ADAPTER, adapter);
TargetMetrics m = adapterMetricsMap.get(adapter);
if (m != null) m.reset();
}
@Override
public synchronized void reloadInterface() throws Exception {
initInterfaces();
interfaceMetricsMap.clear();
}
@Override
public synchronized void reloadInterface(String inter) throws Exception {
reloadBucketMap(interfaceBucketMap, InflowType.INTERFACE, inter);
TargetMetrics m = interfaceMetricsMap.get(inter);
if (m != null) m.reset();
}
@Override
public synchronized void reloadGroup() throws Exception {
super.reloadGroup();
groupMetricsMap.clear();
}
@Override
public synchronized void reloadGroup(String groupId) throws Exception {
super.reloadGroup(groupId);
TargetMetrics m = groupMetricsMap.get(groupId);
if (m != null) m.reset();
}
protected void reloadBucketMap(Map<String, DualCustomBucket> targetMap, InflowType type, String name) throws Exception {
Map<String, InflowTargetVO> updated = inflowControlDAO.getInflowMap(type, name);
if (updated == null) return;
for (InflowTargetVO vo : updated.values()) {
DualCustomBucket bucket = makeBucket(vo);
if (bucket != null) targetMap.put(vo.getName(), bucket);
else targetMap.remove(vo.getName());
}
}
// -------------------------------------------------------------------------
// 어댑터/인터페이스 메트릭 접근자
// -------------------------------------------------------------------------
public TargetMetrics getAdapterMetrics(String adapter) { return adapterMetricsMap.get(adapter); }
public TargetMetrics getInterfaceMetrics(String inter) { return interfaceMetricsMap.get(inter); }
public Map<String, TargetMetrics> getAllAdapterMetrics() { return Collections.unmodifiableMap(adapterMetricsMap); }
public Map<String, TargetMetrics> getAllInterfaceMetrics() { return Collections.unmodifiableMap(interfaceMetricsMap); }
public void resetAdapterMetrics(String adapter) { TargetMetrics m = adapterMetricsMap.get(adapter); if (m != null) m.reset(); }
public void resetInterfaceMetrics(String inter) { TargetMetrics m = interfaceMetricsMap.get(inter); if (m != null) m.reset(); }
public void resetAllAdapterMetrics() { adapterMetricsMap.values().forEach(TargetMetrics::reset); }
public void resetAllInterfaceMetrics() { interfaceMetricsMap.values().forEach(TargetMetrics::reset); }
// -------------------------------------------------------------------------
// 그룹 메트릭 — isGroupPass() 카운팅 및 접근자
// -------------------------------------------------------------------------
@Override
public String isGroupPass(String groupId) {
String result = super.isGroupPass(groupId);
TargetMetrics m = groupMetricsMap.computeIfAbsent(groupId, k -> new TargetMetrics());
if (result == null) {
m.allowed.incrementAndGet();
} else if (CustomGroupBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) {
m.rejectedPerSecond.incrementAndGet();
} else {
m.rejectedThreshold.incrementAndGet();
}
return result;
}
public TargetMetrics getGroupMetrics(String groupId) {
return groupMetricsMap.get(groupId);
}
public Map<String, TargetMetrics> getAllGroupMetrics() {
return Collections.unmodifiableMap(groupMetricsMap);
}
public void resetGroupMetrics(String groupId) {
TargetMetrics m = groupMetricsMap.get(groupId);
if (m != null) m.reset();
}
public void resetAllGroupMetrics() {
groupMetricsMap.values().forEach(TargetMetrics::reset);
}
// -------------------------------------------------------------------------
// DualBucket — 차단 원인 포함 메서드
// -------------------------------------------------------------------------
@Override
public String isAdapterPassDetail(String adapter) {
DualCustomBucket b = adapterBucketMap.get(adapter);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(adapterMetricsMap, adapter, result);
return result;
}
@Override
public String isInterfacePassDetail(String inter) {
DualCustomBucket b = interfaceBucketMap.get(inter);
if (b == null) return DualCustomBucket.RESULT_PASS;
String result = b.tryConsume();
recordMetrics(interfaceMetricsMap, inter, result);
return result;
}
protected void recordMetrics(ConcurrentHashMap<String, TargetMetrics> metricsMap, String key, String result) {
TargetMetrics m = metricsMap.computeIfAbsent(key, k -> new TargetMetrics());
if (result == null) m.allowed.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) m.rejectedPerSecond.incrementAndGet();
else m.rejectedThreshold.incrementAndGet();
}
// -------------------------------------------------------------------------
// Bucket 인터페이스 오버라이드 — 이중 버킷으로 교체 (boolean 반환 유지)
// -------------------------------------------------------------------------
@Override
public boolean isAdapterPass(String adapter) {
return isAdapterPassDetail(adapter) == DualCustomBucket.RESULT_PASS;
}
@Override
public boolean isInterfacePass(String inter) {
return isInterfacePassDetail(inter) == DualCustomBucket.RESULT_PASS;
}
// -------------------------------------------------------------------------
// 어댑터/인터페이스 키·VO·삭제 — Dual 맵 기준으로 오버라이드
// -------------------------------------------------------------------------
@Override
public String[] getAdapterAllKeys() {
String[] keys = adapterBucketMap.keySet().toArray(new String[0]);
Arrays.sort(keys);
return keys;
}
@Override
public String[] getInterfaceAllKeys() {
String[] keys = interfaceBucketMap.keySet().toArray(new String[0]);
Arrays.sort(keys);
return keys;
}
@Override
public InflowTargetVO getAdapterInflowThreashold(String adapter) {
DualCustomBucket b = adapterBucketMap.get(adapter);
return (b == null) ? null : b.getInflowTargetVo();
}
@Override
public InflowTargetVO getInterfaceInflowThreashold(String inter) {
DualCustomBucket b = interfaceBucketMap.get(inter);
return (b == null) ? null : b.getInflowTargetVo();
}
@Override
public synchronized void removeAdapter(String adapter) {
adapterBucketMap.remove(adapter);
adapterMetricsMap.remove(adapter);
}
@Override
public synchronized void removeInterface(String inter) {
interfaceBucketMap.remove(inter);
interfaceMetricsMap.remove(inter);
}
// -------------------------------------------------------------------------
// 모니터링용 접근자
// -------------------------------------------------------------------------
public DualCustomBucket getAdapterBucket(String adapter) {
return adapterBucketMap.get(adapter);
}
public DualCustomBucket getInterfaceBucket(String inter) {
return interfaceBucketMap.get(inter);
}
public Map<String, DualCustomBucket> getAdapterBucketMap() {
return adapterBucketMap;
}
public Map<String, DualCustomBucket> getInterfaceBucketMap() {
return interfaceBucketMap;
}
// -------------------------------------------------------------------------
// 버킷 팩토리
// -------------------------------------------------------------------------
/**
* perSecond 버킷: greedy refill — 초당 N개 요청을 균등하게 허용.
* threshold 버킷: intervally refill — 기간(시/일/월 등) 단위 쿼터를 기간 시작 시 일괄 충전.
* Bandwidth.simple은 두 버킷 모두 greedy로 생성되어 시간 단위 구분이 없어지므로 classic으로 교체.
*/
protected DualCustomBucket makeBucket(InflowTargetVO vo) {
if (!AbstractInflowControlManager.isInflowTarget(vo)) {
return null;
}
LocalBucket perSecondBucket = null;
LocalBucket thresholdBucket = null;
if (vo.getThresholdPerSecond() > 0) {
perSecondBucket = Bucket4j.builder()
.addLimit(Bandwidth.classic(vo.getThresholdPerSecond(),
Refill.greedy(vo.getThresholdPerSecond(), Duration.ofSeconds(1))))
.build();
}
if (vo.getThreshold() > 0 && StringUtils.isNotBlank(vo.getThresholdTimeUnit())) {
Duration period = AbstractInflowControlManager.getPeriod(vo.getThresholdTimeUnit());
thresholdBucket = Bucket4j.builder()
.addLimit(Bandwidth.classic(vo.getThreshold(),
Refill.intervally(vo.getThreshold(), period)))
.build();
}
return new DualCustomBucket(perSecondBucket, thresholdBucket, vo);
}
}
@@ -0,0 +1,173 @@
package com.eactive.eai.common.security;
import java.nio.ByteBuffer;
import java.security.Key;
import java.security.SecureRandom;
import java.security.Security;
import java.security.spec.AlgorithmParameterSpec;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.jcajce.spec.FPEParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class AESCryptoModuleExtension implements CryptoModuleExtension {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final String DEFAULT_PROVIDER = "BC";
private Cipher encryptEngine;
private Cipher decryptEngine;
private String mode;
private String pad;
private byte[] iv;
private byte[] encKey;
private byte[] decKey;
/** 명시적 프로바이더. null이면 모든 모드에서 BC(DEFAULT_PROVIDER)를 사용한다. */
private String provider;
// ------------------------------------------------------------------
// init — 모든 오버로드는 최종적으로 6+provider 버전에 위임한다
// ------------------------------------------------------------------
@Override
public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception {
init(alg, mode, pad, null, encKey, decKey, null);
}
@Override
public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception {
init(alg, mode, pad, iv, encKey, decKey, null);
}
@Override
public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey, String provider) throws Exception {
init(alg, mode, pad, null, encKey, decKey, provider);
}
@Override
public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey, String provider) throws Exception {
this.mode = mode;
this.pad = pad;
this.iv = iv;
this.encKey = encKey;
this.decKey = decKey;
this.provider = (provider != null && !provider.isEmpty()) ? provider : null;
// GCM 포함 모든 모드에서 BC 프로바이더를 사용 — 기본값 BC
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
if (!mode.equalsIgnoreCase("GCM")) {
String m = mode != null ? mode : "";
String p = pad != null ? pad : "";
String transformation = String.format("AES/%s/%s", m, p);
Key encKeySpec = new SecretKeySpec(encKey, "AES");
Key decKeySpec = new SecretKeySpec(decKey, "AES");
AlgorithmParameterSpec param = iv != null ? new IvParameterSpec(iv) : null;
if (m.toLowerCase().contains("ff")) {
param = new FPEParameterSpec(256, iv);
}
String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER;
this.encryptEngine = Cipher.getInstance(transformation, prov);
this.decryptEngine = Cipher.getInstance(transformation, prov);
this.encryptEngine.init(Cipher.ENCRYPT_MODE, encKeySpec, param, (SecureRandom) null);
this.decryptEngine.init(Cipher.DECRYPT_MODE, decKeySpec, param, (SecureRandom) null);
}
}
// ------------------------------------------------------------------
// encrypt
// ------------------------------------------------------------------
@Override
public int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception {
return this.encryptEngine.doFinal(src, sindex, slength, dst, dindex);
}
@Override
public byte[] encrypt(byte[] src, int sindex, int slength) throws Exception {
return encrypt(src, sindex, slength, null);
}
/**
* AAD를 명시한 암호화.
* GCM 모드에서 aad != null 이면 해당 값을 사용하고, null 이면 iv를 AAD로 사용하는 기본 동작을 따른다.
* 비-GCM 모드는 AAD를 무시하고 사전 초기화된 엔진으로 처리한다.
*/
@Override
public byte[] encrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception {
if (this.mode.equalsIgnoreCase("GCM")) {
Key key = new SecretKeySpec(this.encKey, "AES");
byte[] nonce = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(nonce);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, nonce);
String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER;
Cipher cipher = Cipher.getInstance(String.format("AES/%s/%s", this.mode, this.pad), prov);
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
byte[] effectiveAad = aad != null ? aad : this.iv;
if (effectiveAad != null) cipher.updateAAD(effectiveAad);
byte[] cipherTextWithTag = cipher.doFinal(src, sindex, slength);
byte[] output = new byte[GCM_IV_LENGTH + cipherTextWithTag.length];
System.arraycopy(nonce, 0, output, 0, GCM_IV_LENGTH);
System.arraycopy(cipherTextWithTag, 0, output, GCM_IV_LENGTH, cipherTextWithTag.length);
return output;
}
int outLen = this.encryptEngine.getOutputSize(slength);
byte[] dst = new byte[outLen];
int actual = encrypt(src, sindex, slength, dst, 0);
return ByteBuffer.allocate(actual).put(dst, 0, actual).array();
}
// ------------------------------------------------------------------
// decrypt
// ------------------------------------------------------------------
@Override
public int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception {
return this.decryptEngine.doFinal(src, sindex, slength, dst, dindex);
}
@Override
public byte[] decrypt(byte[] src, int sindex, int slength) throws Exception {
return decrypt(src, sindex, slength, null);
}
/**
* AAD를 명시한 복호화.
* GCM 모드에서 aad != null 이면 해당 값을 사용하고, null 이면 iv를 AAD로 사용하는 기본 동작을 따른다.
* 비-GCM 모드는 AAD를 무시하고 사전 초기화된 엔진으로 처리한다.
*/
@Override
public byte[] decrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception {
if (this.mode.equalsIgnoreCase("GCM")) {
Key key = new SecretKeySpec(this.decKey, "AES");
byte[] nonce = new byte[GCM_IV_LENGTH];
System.arraycopy(src, sindex, nonce, 0, GCM_IV_LENGTH);
byte[] cipherTextWithTag = new byte[slength - GCM_IV_LENGTH];
System.arraycopy(src, sindex + GCM_IV_LENGTH, cipherTextWithTag, 0, cipherTextWithTag.length);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, nonce);
String prov = this.provider != null ? this.provider : DEFAULT_PROVIDER;
Cipher cipher = Cipher.getInstance(String.format("AES/%s/%s", this.mode, this.pad), prov);
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
byte[] effectiveAad = aad != null ? aad : this.iv;
if (effectiveAad != null) cipher.updateAAD(effectiveAad);
return cipher.doFinal(cipherTextWithTag);
}
return this.decryptEngine.doFinal(src, sindex, slength);
}
}
@@ -0,0 +1,68 @@
package com.eactive.eai.common.security;
import java.nio.ByteBuffer;
import java.security.Key;
import java.security.SecureRandom;
import java.security.Security;
import java.security.spec.AlgorithmParameterSpec;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.jcajce.spec.FPEParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class ARIACryptoModuleExtension implements CryptoModuleExtension {
private Cipher encryptEngine;
private Cipher decryptEngine;
@Override
public void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception {
this.init("ARIA", mode, pad, (byte[]) null, encKey, decKey);
}
@Override
public void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
String m = mode != null ? mode : "";
String p = pad != null ? pad : "";
Key encKeySpec = new SecretKeySpec(encKey, "ARIA");
Key decKeySpec = new SecretKeySpec(decKey, "ARIA");
AlgorithmParameterSpec param = iv != null ? new IvParameterSpec(iv) : null;
if (m.toLowerCase().contains("ff")) {
param = new FPEParameterSpec(256, iv);
}
this.encryptEngine = Cipher.getInstance(String.format("ARIA/%s/%s", m, p));
this.encryptEngine.init(Cipher.ENCRYPT_MODE, encKeySpec, param, (SecureRandom) null);
this.decryptEngine = Cipher.getInstance(String.format("ARIA/%s/%s", m, p));
this.decryptEngine.init(Cipher.DECRYPT_MODE, decKeySpec, param, (SecureRandom) null);
}
@Override
public int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception {
return this.encryptEngine.doFinal(src, sindex, slength, dst, dindex);
}
@Override
public byte[] encrypt(byte[] src, int sindex, int slength) throws Exception {
int size = (slength + 17) / this.encryptEngine.getBlockSize() * this.encryptEngine.getBlockSize();
size += (slength + 17) % this.encryptEngine.getBlockSize() == 0 ? 0 : this.encryptEngine.getBlockSize();
byte[] dst = new byte[this.encryptEngine.getOutputSize(size)];
size = this.encrypt(src, sindex, slength, dst, 0);
return ByteBuffer.allocate(size).put(dst, 0, size).array();
}
@Override
public int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception {
return this.decryptEngine.doFinal(src, sindex, slength, dst, dindex);
}
@Override
public byte[] decrypt(byte[] src, int sindex, int slength) throws Exception {
return this.decryptEngine.doFinal(src, sindex, slength);
}
}
@@ -0,0 +1,23 @@
package com.eactive.eai.common.security;
import lombok.Data;
@Data
public class CryptoModuleConfigVO {
String cryptoId;
String cryptoName;
String cryptoDesc;
String algType;
String cipherMode;
String padding;
String ivHex;
String keySourceType;
String encKeyHex;
String decKeyHex;
String keyDerivStrategy;
String keyDerivParams;
String cacheYn;
Integer cacheTtlSec;
String useYn;
}
@@ -0,0 +1,52 @@
package com.eactive.eai.common.security;
public interface CryptoModuleExtension {
void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey) throws Exception;
void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey) throws Exception;
/** 프로바이더를 명시한 초기화. 미지원 구현체는 provider를 무시하고 기본 동작한다. */
default void init(String alg, String mode, String pad, byte[] encKey, byte[] decKey, String provider) throws Exception {
init(alg, mode, pad, encKey, decKey);
}
/** 프로바이더를 명시한 초기화 (IV 포함). 미지원 구현체는 provider를 무시하고 기본 동작한다. */
default void init(String alg, String mode, String pad, byte[] iv, byte[] encKey, byte[] decKey, String provider) throws Exception {
init(alg, mode, pad, iv, encKey, decKey);
}
int encrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception;
byte[] encrypt(byte[] src, int sindex, int slength) throws Exception;
/** AAD를 명시한 암호화. null이면 구현체 기본 동작(IV를 AAD로 사용하거나 AAD 없음)을 따른다. */
default byte[] encrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception {
return encrypt(src, sindex, slength);
}
default byte[] encrypt(byte[] src) throws Exception {
return encrypt(src, 0, src.length);
}
default byte[] encrypt(byte[] src, byte[] aad) throws Exception {
return encrypt(src, 0, src.length, aad);
}
int decrypt(byte[] src, int sindex, int slength, byte[] dst, int dindex) throws Exception;
byte[] decrypt(byte[] src, int sindex, int slength) throws Exception;
/** AAD를 명시한 복호화. null이면 구현체 기본 동작을 따른다. */
default byte[] decrypt(byte[] src, int sindex, int slength, byte[] aad) throws Exception {
return decrypt(src, sindex, slength);
}
default byte[] decrypt(byte[] src) throws Exception {
return decrypt(src, 0, src.length);
}
default byte[] decrypt(byte[] src, byte[] aad) throws Exception {
return decrypt(src, 0, src.length, aad);
}
}
@@ -0,0 +1,244 @@
package com.eactive.eai.common.security;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.xml.bind.DatatypeConverter;
import org.springframework.stereotype.Component;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@Component
public class CryptoModuleManager implements Lifecycle {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final ObjectMapper objectMapper = new ObjectMapper();
private final ConcurrentHashMap<String, CryptoModuleConfigVO> configMap = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, KeyDerivationStrategy> strategyCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, CachedDerivedKey> dynamicKeyCache = new ConcurrentHashMap<>();
private boolean started;
private final LifecycleSupport lifecycle = new LifecycleSupport(this);
private CryptoModuleManager() {}
public static CryptoModuleManager getInstance() {
return ApplicationContextProvider.getContext().getBean(CryptoModuleManager.class);
}
@Override
public void start() throws LifecycleException {
if (started) throw new LifecycleException("RECEAICRY001");
lifecycle.fireLifecycleEvent(STARTING_EVENT, this);
try {
loadAll();
} catch (Exception e) {
throw new LifecycleException("RECEAICRY002");
}
started = true;
lifecycle.fireLifecycleEvent(STARTED_EVENT, this);
}
@Override
public void stop() throws LifecycleException {
if (!started) throw new LifecycleException("RECEAICRY003");
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
configMap.clear();
dynamicKeyCache.clear();
strategyCache.clear();
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
private void loadAll() {
CryptoModuleConfigLoader loader = ctx().getBean(CryptoModuleConfigLoader.class);
CryptoModuleConfigMapper mapper = ctx().getBean(CryptoModuleConfigMapper.class);
List<CryptoModuleConfig> all = loader.findAll();
configMap.clear();
for (CryptoModuleConfig entity : all) {
if ("Y".equalsIgnoreCase(entity.getUseYn())) {
CryptoModuleConfigVO vo = mapper.toVo(entity);
configMap.put(vo.getCryptoName(), vo);
}
}
logger.warn("CryptoModuleManager] 암호화모듈 로드 완료. 건수=" + configMap.size());
}
public void reload(String cryptoId) {
CryptoModuleConfigLoader loader = ctx().getBean(CryptoModuleConfigLoader.class);
CryptoModuleConfigMapper mapper = ctx().getBean(CryptoModuleConfigMapper.class);
configMap.values().removeIf(vo -> cryptoId.equals(vo.getCryptoId()));
evictDynamicCache(cryptoId);
loader.findById(cryptoId).ifPresent(entity -> {
if ("Y".equalsIgnoreCase(entity.getUseYn())) {
CryptoModuleConfigVO vo = mapper.toVo(entity);
configMap.put(vo.getCryptoName(), vo);
logger.warn("CryptoModuleManager] 재로드 완료: " + vo.getCryptoName());
}
});
}
/**
* STATIC 키 방식 — 초기화된 CryptoModuleExtension 반환.
* Cipher는 thread-safe하지 않으므로 호출마다 새 인스턴스를 생성한다.
*/
public CryptoModuleExtension createExtension(String cryptoName) throws Exception {
CryptoModuleConfigVO vo = getVO(cryptoName);
byte[] encKey = DatatypeConverter.parseHexBinary(vo.getEncKeyHex());
byte[] decKey = vo.getDecKeyHex() != null
? DatatypeConverter.parseHexBinary(vo.getDecKeyHex())
: encKey;
byte[] iv = vo.getIvHex() != null ? DatatypeConverter.parseHexBinary(vo.getIvHex()) : null;
return buildExtension(vo, iv, encKey, decKey);
}
/**
* DYNAMIC 키 방식 — 전략으로 키를 도출하고 캐시를 적용한 뒤 CryptoModuleExtension 반환.
*/
public CryptoModuleExtension createExtension(String cryptoName, Map<String, String> runtimeContext)
throws Exception {
CryptoModuleConfigVO vo = getVO(cryptoName);
if ("STATIC".equalsIgnoreCase(vo.getKeySourceType())) {
return createExtension(cryptoName);
}
KeyDerivationStrategy strategy = resolveStrategy(vo.getKeyDerivStrategy());
Map<String, String> params = parseParams(vo.getKeyDerivParams());
String cacheKey = strategy.buildCacheKey(cryptoName, params, runtimeContext);
DerivedKey derivedKey = null;
if ("Y".equalsIgnoreCase(vo.getCacheYn())) {
CachedDerivedKey cached = dynamicKeyCache.get(cacheKey);
if (cached != null && !cached.isExpired()) {
derivedKey = cached.getDerivedKey();
}
}
if (derivedKey == null) {
derivedKey = strategy.deriveKey(params, runtimeContext);
if ("Y".equalsIgnoreCase(vo.getCacheYn())) {
int ttl = vo.getCacheTtlSec() != null ? vo.getCacheTtlSec() : 300;
dynamicKeyCache.put(cacheKey, new CachedDerivedKey(derivedKey, ttl));
}
}
byte[] iv = vo.getIvHex() != null ? DatatypeConverter.parseHexBinary(vo.getIvHex()) : null;
return buildExtension(vo, iv, derivedKey.getEncKey(), derivedKey.getDecKey());
}
private CryptoModuleConfigVO getVO(String cryptoName) {
CryptoModuleConfigVO vo = configMap.get(cryptoName);
if (vo == null) {
throw new IllegalArgumentException("암호화모듈 미등록: " + cryptoName);
}
return vo;
}
private CryptoModuleExtension buildExtension(CryptoModuleConfigVO vo, byte[] iv,
byte[] encKey, byte[] decKey) throws Exception {
CryptoModuleExtension ext = "ARIA".equalsIgnoreCase(vo.getAlgType())
? new ARIACryptoModuleExtension()
: new AESCryptoModuleExtension();
ext.init(vo.getAlgType(), vo.getCipherMode(), vo.getPadding(), iv, encKey, decKey);
return ext;
}
/**
* DB key_deriv_strategy 컬럼에 저장된 FQCN으로 전략 클래스를 로드한다.
* 한 번 로드된 인스턴스는 strategyCache에 보관하여 재사용한다.
*/
private KeyDerivationStrategy resolveStrategy(String fqcn) {
return strategyCache.computeIfAbsent(fqcn, key -> {
try {
Class<?> clazz = Class.forName(key);
return (KeyDerivationStrategy) clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new IllegalArgumentException("전략 클래스 로드 실패: " + key, e);
}
});
}
private Map<String, String> parseParams(String json) throws Exception {
if (json == null || json.trim().isEmpty()) {
return new HashMap<>();
}
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {});
}
private void evictDynamicCache(String cryptoId) {
configMap.values().stream()
.filter(vo -> cryptoId.equals(vo.getCryptoId()))
.map(CryptoModuleConfigVO::getCryptoName)
.forEach(name -> {
Iterator<String> it = dynamicKeyCache.keySet().iterator();
while (it.hasNext()) {
if (it.next().startsWith(name + ":")) it.remove();
}
});
}
private org.springframework.context.ApplicationContext ctx() {
return ApplicationContextProvider.getContext();
}
@Override
public void addLifecycleListener(LifecycleListener listener) {
lifecycle.addLifecycleListener(listener);
}
@Override
public LifecycleListener[] findLifecycleListeners() {
return lifecycle.findLifecycleListeners();
}
@Override
public void removeLifecycleListener(LifecycleListener listener) {
lifecycle.removeLifecycleListener(listener);
}
@Override
public boolean isStarted() {
return started;
}
private static class CachedDerivedKey {
private final DerivedKey derivedKey;
private final long expireAt;
CachedDerivedKey(DerivedKey derivedKey, int ttlSec) {
this.derivedKey = derivedKey;
this.expireAt = System.currentTimeMillis() + (ttlSec * 1000L);
}
boolean isExpired() {
return System.currentTimeMillis() > expireAt;
}
DerivedKey getDerivedKey() {
return derivedKey;
}
}
}
@@ -0,0 +1,77 @@
package com.eactive.eai.common.security;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* 어댑터 필터 등에서 암복호화를 위임하는 진입점.
*
* STATIC 키 방식:
* service.encrypt("MODULE_NAME", plainBytes);
*
* DYNAMIC 키 방식 (런타임 컨텍스트 전달):
* Map<String,String> ctx = Map.of("X-Api-Enc-Key", request.getHeader("X-Api-Enc-Key"));
* service.encrypt("MODULE_NAME", ctx, plainBytes);
*
* AAD 명시 방식 (GCM 인증 데이터 전달):
* byte[] aad = request.getHeader("X-Api-Request-Id").getBytes(StandardCharsets.UTF_8);
* service.encrypt("MODULE_NAME", ctx, aad, plainBytes);
*/
@Service
public class CryptoModuleService {
public static CryptoModuleService getInstance() {
return ApplicationContextProvider.getContext().getBean(CryptoModuleService.class);
}
// ------------------------------------------------------------------
// STATIC 키
// ------------------------------------------------------------------
public byte[] encrypt(String cryptoName, byte[] plainBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName);
return ext.encrypt(plainBytes, 0, plainBytes.length);
}
public byte[] encrypt(String cryptoName, byte[] aad, byte[] plainBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName);
return ext.encrypt(plainBytes, 0, plainBytes.length, aad);
}
public byte[] decrypt(String cryptoName, byte[] cipherBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName);
return ext.decrypt(cipherBytes, 0, cipherBytes.length);
}
public byte[] decrypt(String cryptoName, byte[] aad, byte[] cipherBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName);
return ext.decrypt(cipherBytes, 0, cipherBytes.length, aad);
}
// ------------------------------------------------------------------
// DYNAMIC 키
// ------------------------------------------------------------------
public byte[] encrypt(String cryptoName, Map<String, String> runtimeContext, byte[] plainBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext);
return ext.encrypt(plainBytes, 0, plainBytes.length);
}
public byte[] encrypt(String cryptoName, Map<String, String> runtimeContext, byte[] aad, byte[] plainBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext);
return ext.encrypt(plainBytes, 0, plainBytes.length, aad);
}
public byte[] decrypt(String cryptoName, Map<String, String> runtimeContext, byte[] cipherBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext);
return ext.decrypt(cipherBytes, 0, cipherBytes.length);
}
public byte[] decrypt(String cryptoName, Map<String, String> runtimeContext, byte[] aad, byte[] cipherBytes) throws Exception {
CryptoModuleExtension ext = CryptoModuleManager.getInstance().createExtension(cryptoName, runtimeContext);
return ext.decrypt(cipherBytes, 0, cipherBytes.length, aad);
}
}
@@ -0,0 +1,20 @@
package com.eactive.eai.common.security.keyderiv;
public class DerivedKey {
private final byte[] encKey;
private final byte[] decKey;
public DerivedKey(byte[] encKey, byte[] decKey) {
this.encKey = encKey;
this.decKey = decKey != null ? decKey : encKey;
}
public byte[] getEncKey() {
return encKey;
}
public byte[] getDecKey() {
return decKey;
}
}
@@ -0,0 +1,23 @@
package com.eactive.eai.common.security.keyderiv;
import java.util.Map;
public interface KeyDerivationStrategy {
/**
* 키를 도출한다.
*
* @param params DB의 key_deriv_params (JSON 파싱된 Map)
* @param runtimeContext 런타임 컨텍스트 (HTTP 헤더, 거래 정보 등 호출자가 채워서 전달)
*/
DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception;
/**
* 캐시 키를 생성한다. 전략마다 어떤 runtimeContext 값이 키를 결정하는지 다르다.
*
* @param cryptoName 암호화모듈 이름
* @param params DB의 key_deriv_params
* @param runtimeContext 런타임 컨텍스트
*/
String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext);
}
@@ -0,0 +1,40 @@
package com.eactive.eai.common.security.keyderiv.strategy;
import java.util.Map;
import javax.crypto.SecretKey;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.hsm.HsmException;
import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HSM 마스터키를 이용하는 전략 클래스들의 기본 클래스
*/
public abstract class BaseHsmKeyDerivationStrategy implements KeyDerivationStrategy {
protected static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias";
protected byte[] getHsmKey(Map<String, String> params) throws HsmException {
String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS);
return getHsmKey(hsmKeyAlias);
}
protected byte[] getHsmKey(String hsmKeyAlias) throws HsmException {
SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias);
return masterKey.getEncoded();
}
private String required(Map<String, String> params, String key) {
String value = params.get(key);
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("key_deriv_params 필수값 누락: " + key);
}
return value;
}
private HsmCryptoService hsmCryptoService() {
return ApplicationContextProvider.getContext().getBean(HsmCryptoService.class);
}
}
@@ -0,0 +1,65 @@
package com.eactive.eai.common.security.keyderiv.strategy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import javax.crypto.SecretKey;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HSM 마스터키 + 런타임 컨텍스트값을 연결(concatenate)한 뒤 SHA-256 해싱으로 키를 도출하는 전략.
* SHA-256 출력은 항상 32바이트(AES-256)이므로 별도 keyLength 파라미터가 불필요하다.
*
* key_deriv_params (JSON) 예시:
* {
* "hsmKeyAlias" : "MASTER_KEY_AES",
* "contextKey" : "X-Api-Group-Seq" -- runtimeContext에서 꺼낼 키 이름
* }
*/
public class HsmContextSha256KeyDerivationStrategy implements KeyDerivationStrategy {
private static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias";
private static final String PARAM_CONTEXT_KEY = "contextKey";
@Override
public DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception {
String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS);
String contextKey = required(params, PARAM_CONTEXT_KEY);
SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias);
byte[] masterKeyBytes = masterKey.getEncoded();
byte[] contextBytes = runtimeContext.getOrDefault(contextKey, "")
.getBytes(StandardCharsets.UTF_8);
byte[] combined = new byte[masterKeyBytes.length + contextBytes.length];
System.arraycopy(masterKeyBytes, 0, combined, 0, masterKeyBytes.length);
System.arraycopy(contextBytes, 0, combined, masterKeyBytes.length, contextBytes.length);
byte[] derived = MessageDigest.getInstance("SHA-256").digest(combined);
return new DerivedKey(derived, derived);
}
@Override
public String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext) {
String contextKey = params.getOrDefault(PARAM_CONTEXT_KEY, "");
String contextValue = runtimeContext.getOrDefault(contextKey, "");
return cryptoName + ":" + contextValue;
}
private String required(Map<String, String> params, String key) {
String value = params.get(key);
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("key_deriv_params 필수값 누락: " + key);
}
return value;
}
private HsmCryptoService hsmCryptoService() {
return ApplicationContextProvider.getContext().getBean(HsmCryptoService.class);
}
}
@@ -0,0 +1,80 @@
package com.eactive.eai.common.security.keyderiv.strategy;
import java.util.Arrays;
import java.util.Map;
import javax.crypto.SecretKey;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HSM 마스터키 + 런타임 컨텍스트값 XOR 조합으로 키를 도출하는 전략.
*
* key_deriv_params (JSON) 예시:
* {
* "hsmKeyAlias" : "MASTER_KEY_AES",
* "contextKey" : "X-Api-Enc-Key", -- runtimeContext에서 꺼낼 키 이름
* "offset" : "0", -- contextKey 값 중 사용할 시작 위치 (byte)
* "length" : "16" -- contextKey 값 중 사용할 길이 (byte)
* }
*/
public class HsmContextXorKeyDerivationStrategy implements KeyDerivationStrategy {
/** DB key_deriv_strategy 컬럼에 사용하는 FQCN 참조용 상수. */
public static final String STRATEGY_CLASS = HsmContextXorKeyDerivationStrategy.class.getName();
private static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias";
private static final String PARAM_CONTEXT_KEY = "contextKey";
private static final String PARAM_OFFSET = "offset";
private static final String PARAM_LENGTH = "length";
@Override
public DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception {
String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS);
String contextKey = required(params, PARAM_CONTEXT_KEY);
int offset = Integer.parseInt(params.getOrDefault(PARAM_OFFSET, "0"));
int length = Integer.parseInt(required(params, PARAM_LENGTH));
SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias);
byte[] masterBytes = masterKey.getEncoded();
String contextValue = runtimeContext.getOrDefault(contextKey, "");
byte[] contextBytes = contextValue.getBytes("UTF-8");
byte[] derived = xor(masterBytes, contextBytes, offset, length);
return new DerivedKey(derived, derived);
}
@Override
public String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext) {
String contextKey = params.getOrDefault(PARAM_CONTEXT_KEY, "");
String contextValue = runtimeContext.getOrDefault(contextKey, "");
return cryptoName + ":" + contextValue;
}
private byte[] xor(byte[] masterBytes, byte[] contextBytes, int offset, int length) {
byte[] derived = Arrays.copyOf(masterBytes, masterBytes.length);
for (int i = 0; i < length && i < derived.length; i++) {
int contextIdx = offset + i;
if (contextIdx < contextBytes.length) {
derived[i] ^= contextBytes[contextIdx];
}
}
return derived;
}
private String required(Map<String, String> params, String key) {
String value = params.get(key);
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("key_deriv_params 필수값 누락: " + key);
}
return value;
}
private HsmCryptoService hsmCryptoService() {
return ApplicationContextProvider.getContext().getBean(HsmCryptoService.class);
}
}
@@ -0,0 +1,24 @@
package com.eactive.eai.common.security.keyderiv.strategy;
import java.util.Map;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
/**
* HSM 마스터키를 사용하는 전략
*
* key_deriv_params (JSON) 예시: { "hsmKeyAlias" : "MASTER_KEY_AES"}
*/
public class HsmKeyDerivationStrategy extends BaseHsmKeyDerivationStrategy {
@Override
public DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception {
byte[] masterKeyBytes = super.getHsmKey(params);
return new DerivedKey(masterKeyBytes, masterKeyBytes);
}
@Override
public String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext) {
return cryptoName;
}
}
@@ -0,0 +1,30 @@
package com.eactive.eai.common.security.keyderiv.strategy;
import java.security.MessageDigest;
import java.util.Map;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
/**
* HSM 마스터키를 SHA-256 해싱으로 키를 도출하는 전략.
* SHA-256 출력은 항상 32바이트(AES-256)이므로 별도 keyLength 파라미터가 불필요하다.
*
* key_deriv_params (JSON) 예시:
* {
* "hsmKeyAlias" : "MASTER_KEY_AES"
* }
*/
public class HsmSha256KeyDerivationStrategy extends BaseHsmKeyDerivationStrategy {
@Override
public DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception {
byte[] masterKeyBytes = super.getHsmKey(params);
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKeyBytes);
return new DerivedKey(derived, derived);
}
@Override
public String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext) {
return cryptoName;
}
}
@@ -0,0 +1,19 @@
package com.eactive.eai.common.security.mapper;
import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
import com.eactive.eai.common.security.CryptoModuleConfigVO;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
import com.eactive.eai.data.mapper.GenericMapper;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface CryptoModuleConfigMapper extends GenericMapper<CryptoModuleConfigVO, CryptoModuleConfig> {
@Override
CryptoModuleConfigVO toVo(CryptoModuleConfig entity);
@InheritInverseConfiguration
CryptoModuleConfig toEntity(CryptoModuleConfigVO vo);
}
@@ -88,8 +88,8 @@ import java.util.Map;
public class RequestProcessor extends RequestProcessorSupport {
private static String instid = null;
private static String serverName = null;
protected static String instid = null;
protected static String serverName = null;
//private static boolean isPEAIServer = true;
static {
@@ -100,9 +100,9 @@ public class RequestProcessor extends RequestProcessorSupport {
instid = eaiServerManager.getGroupInstId();
}
private static boolean httpHeaderLogMode = HttpAdapterExtraLogUtil.isHttpHeaderMode();
protected static boolean httpHeaderLogMode = HttpAdapterExtraLogUtil.isHttpHeaderMode();
private ProcessVO setVO(Properties prop,long msgRcvTm) throws RequestProcessorException{
protected ProcessVO setVO(Properties prop,long msgRcvTm) throws RequestProcessorException{
String actionName = prop.getProperty(Processor.REQUEST_ACTION);
String adapterGroupName = prop.getProperty(Processor.ADAPTER_GROUP_NAME);
String adapterName = prop.getProperty(Processor.ADAPTER_NAME);
@@ -1033,13 +1033,20 @@ public class RequestProcessor extends RequestProcessorSupport {
Bucket bucket = InflowControlUtil.getBucket();
String blockedType = "";
// Client 유량제어 체크 — clientId가 있는 경우에만
String clientBlockedType = checkClientInflow(bucket, clientId);
if (clientBlockedType != null) {
blockedType += "C";
}
// Adapter 유량제어 체크
if (!bucket.isAdapterPass(vo.getAdapterGroupName())) {
String adapterBlockedType = checkAdapterInflow(bucket, vo.getAdapterGroupName());
if (adapterBlockedType != null) {
blockedType += "A";
}
// 그룹 우선, 없으면 인터페이스 유량제어 체크
String groupId = bucket.getGroupIdByInterface(vo.getEaiSvcCd());
String groupBlockedType = null;
String interfaceBlockedType = null;
if (groupId != null) {
// 그룹에 속한 인터페이스 → 그룹 유량제어 체크
groupBlockedType = bucket.isGroupPass(groupId);
@@ -1048,7 +1055,8 @@ public class RequestProcessor extends RequestProcessorSupport {
}
} else {
// 그룹에 속하지 않은 인터페이스 → 인터페이스 유량제어 체크
if (!bucket.isInterfacePass(vo.getEaiSvcCd())) {
interfaceBlockedType = checkInterfaceInflow(bucket, vo.getEaiSvcCd());
if (interfaceBlockedType != null) {
blockedType += "I";
}
}
@@ -1057,7 +1065,9 @@ public class RequestProcessor extends RequestProcessorSupport {
if (blockedType.length() > 0) {
InflowTargetVO inflowTargetVO = null;
InflowGroupVO inflowGroupVO = null;
if (blockedType.startsWith("A")) {
if (blockedType.startsWith("C")) {
inflowTargetVO = resolveClientInflowThreshold(bucket, clientId);
} else if (blockedType.startsWith("A")) {
inflowTargetVO = bucket.getAdapterInflowThreashold(vo.getAdapterGroupName());
} else if (blockedType.contains("G")) {
inflowGroupVO = bucket.getGroupInflowThreshold(groupId);
@@ -1078,7 +1088,11 @@ public class RequestProcessor extends RequestProcessorSupport {
inflowGroupVO.getThresholdTimeUnit());
}
} else if (inflowTargetVO != null) {
inflowErrorMsg = String.format("API 호출 한도 초과 [%s]", inflowTargetVO.getName());
String targetBlockedType;
if (blockedType.startsWith("C")) targetBlockedType = clientBlockedType;
else if (blockedType.startsWith("A")) targetBlockedType = adapterBlockedType;
else targetBlockedType = interfaceBlockedType;
inflowErrorMsg = buildInflowTargetErrorMsg(inflowTargetVO, targetBlockedType);
} else {
inflowErrorMsg = "API 호출 한도 초과";
}
@@ -1384,7 +1398,29 @@ public class RequestProcessor extends RequestProcessorSupport {
}
}
private String getInboundErrorResponse(Properties prop, StandardMessage standardMessage, InterfaceMapper mapper
// 클라이언트 유량제어 체크 훅 — 기본 구현은 체크 없음, DJErpRequestProcessor가 오버라이드
protected String checkClientInflow(Bucket bucket, String clientId) {
return null;
}
protected InflowTargetVO resolveClientInflowThreshold(Bucket bucket, String clientId) {
return null;
}
// 어댑터/인터페이스 유량제어 체크 훅 — 서브클래스에서 오버라이드하여 차단 원인(PER_SECOND/THRESHOLD)을 반환 가능
protected String checkAdapterInflow(Bucket bucket, String adapterGroupName) {
return bucket.isAdapterPass(adapterGroupName) ? null : "BLOCKED";
}
protected String checkInterfaceInflow(Bucket bucket, String eaiSvcCd) {
return bucket.isInterfacePass(eaiSvcCd) ? null : "BLOCKED";
}
protected String buildInflowTargetErrorMsg(InflowTargetVO inflowTargetVO, String blockedType) {
return String.format("API 호출 한도 초과 [%s]", inflowTargetVO.getName());
}
protected String getInboundErrorResponse(Properties prop, StandardMessage standardMessage, InterfaceMapper mapper
, String errCode, String errMsg, String charset) {
String errorResponse = null;
@@ -1413,7 +1449,7 @@ public class RequestProcessor extends RequestProcessorSupport {
return errorResponse;
}
private String getInboundErrorResponseForStandard(StandardMessage standardMessage, InterfaceMapper mapper,
protected String getInboundErrorResponseForStandard(StandardMessage standardMessage, InterfaceMapper mapper,
String errCode, String errMsg, String charset, String messageType) {
mapper.nextGuidSeq(standardMessage);
mapper.setSendRecvDivision(standardMessage, STDMessageKeys.SEND_RECV_CD_RECV);
@@ -17,6 +17,7 @@ import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.common.EAIKeys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.header.HeaderAction;
@@ -352,24 +353,26 @@ public class HTTPProcess extends DefaultProcess {
this.tempProp.put(HttpClientAdapterServiceKey.INSTANCE_ID, instanceId);
}
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// RESTAdapter는 교체하지 않는다
&& !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
} else {
this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
}
this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
} else {
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
}
// EAIServerManager eaiServerManager = EAIServerManager.getInstance();
// AdapterManager adapterManager = AdapterManager.getInstance();
// AdapterGroupVO adptGrpVO = adapterManager.getAdapterGroupVO(reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp());
// if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())
// // RESTAdapter는 교체하지 않는다
// && !StringUtils.equals(adptGrpVO.getType(), com.eactive.eai.adapter.Keys.TYPE_REST)) {
// PropManager manager = PropManager.getInstance();
// Properties prop = manager.getProperties(RouteKeys.SIM);
//
// if (reqEaiMsg.getCurrentSvcMsg().isSyncItfTp()) {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_SYNC, "_SIM_OU_HTT_SyC");
// } else {
// this.adapterGroupName = prop.getProperty(RouteKeys.SIM_ADAPTER_ASYNC, "_SIM_OU_HTT_AsC");
// }
// this.reqEaiMsg.getSvcMsg(0).setPsvSysItfTp(this.adapterGroupName);
// } else {
// this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
// }
this.adapterGroupName = reqEaiMsg.getCurrentSvcMsg().getPsvSysItfTp();
this.svcTsmtUsgTp = this.reqEaiMsg.getSvcTsmtUsgTp(); // Inbound 호출방식
this.psvItfTp = this.reqEaiMsg.getCurrentSvcMsg().getPsvItfTp(); // 호출방식
@@ -404,6 +407,7 @@ public class HTTPProcess extends DefaultProcess {
}
// Inbound Adapter mesageType
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterGroupVO inAdptGrpVO = adapterManager.getAdapterGroupVO(this.reqEaiMsg.getSngSysItfTp());
this.inAdapterMsgType = MessageType.ASC;
@@ -592,6 +596,22 @@ public class HTTPProcess extends DefaultProcess {
logger.debug(guidLogPrefix + " HTTP NONSTANDARD Request Message : "
+ MessageUtil.toAdapterTypeString(this.reqObject, this.inboundCharset));
}
// TAS 선택시 시뮬레이터 url로 변경, HTT 어댑터도 RST 처럼 방식을 변경하였다.
EAIServerManager eaiServerManager = EAIServerManager.getInstance();
if (eaiServerManager.isTASEnabledEAIServer() && EAIMessageKeys.TRANTYPE_TAS.equals(reqEaiMsg.getTranType())) {
PropManager manager = PropManager.getInstance();
Properties prop = manager.getProperties(RouteKeys.SIM);
String simAddr = prop.getProperty(RouteKeys.SIM_REST_MOCKSERVER);
if (StringUtils.isBlank(simAddr)) {
throw new Exception(String.format("Properties %s > %s not setted", RouteKeys.SIM,
RouteKeys.SIM_REST_MOCKSERVER));
}
String url = StringUtils.removeEnd(simAddr, "/") + "/mockapi/" + this.reqEaiMsg.getEAISvcCd();
this.outboundProp.put(HttpClientAdapterServiceKey.URL, url);
this.tempProp.remove(HttpAdapterServiceKey.INBOUND_REWRITE_PATH);
logger.debug("simUrl=" + url);
}
} catch (Exception e) {
String[] msgArgs = new String[1];
msgArgs[0] = this.reqEaiMsg.getEAISvcCd();
@@ -0,0 +1,6 @@
# SoftHSM2 for Windows - 개발 환경
# https://github.com/disig/SoftHSM2-for-Windows/releases 에서 설치
# 토큰 초기화: softhsm2-util --init-token --slot 0 --label "dev-token" --pin 1234 --so-pin 1234
name = SoftHSM
library = C:/SoftHSM2/lib/softhsm2-x64.dll
slotListIndex = 0
@@ -0,0 +1,8 @@
# SafeNet ProtectServer Network HSM - 운영 환경
# 환경변수 필요:
# ET_HSM_NETCLIENT_SERVERLIST=<HSM 서버 IP>
# ET_HSM_NETCLIENT_SERVERPORT=9000
# LD_LIBRARY_PATH=/opt/safenet/protecttoolkit5/ptk/lib
name = ProtectServer
library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so
slot = 0
@@ -0,0 +1,725 @@
package com.eactive.eai.adapter.handler;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Properties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.eactive.eai.message.StandardType;
/**
* TemplateAdapterErrorMsgHandler 단위테스트
*
* - render() : package-private 이므로 같은 패키지에서 직접 호출
* - generateNonStandardErrorResponseMessage() : ApplicationContextProvider
* mock ApplicationContext 리플렉션으로 주입하여 PropManager 격리
*/
class TemplateAdapterErrorMsgHandlerTest {
private TemplateAdapterErrorMsgHandler handler;
@BeforeEach
void setUp() {
handler = new TemplateAdapterErrorMsgHandler();
}
// ================================================================
// StandardMessage 빌더 헬퍼
// ================================================================
/** FIELD 아이템 (type=2) */
private static StandardItem field(String name, String value) {
return new StandardItem(name, 3, StandardType.FIELD, 1, 0, 300, 1, "", "", value, name);
}
/** GROUP 아이템 (type=3, size=1 → 활성) */
private static StandardItem group(String name) {
return new StandardItem(name, 2, StandardType.GROUP, 1, 0, 0, 1, "", "", null, name);
}
/**
* DJB 표준전문 MSG 영역을 모사한 StandardMessage 생성.
*
* 구조:
* MSG (GROUP)
* MAIN_MSG (GROUP)
* outp_msg_cd, outp_msg_ctnt, outp_msg_desc
* MSG_LIST (GRID)
* row[n]: outp_msg_cd, outp_msg_ctnt, outp_msg_desc, err_occu_item_nm
*
* @param listRows null 또는 배열이면 MSG_LIST 없음
*/
private StandardMessage buildMsg(String errCode, String errMsg, String errDesc,
String[][] listRows) {
StandardMessage msg = new StandardMessage();
StandardItem msgGroup = group("MSG");
StandardItem mainMsg = group("MAIN_MSG");
mainMsg.addItem(field("outp_msg_cd", errCode));
mainMsg.addItem(field("outp_msg_ctnt", errMsg));
mainMsg.addItem(field("outp_msg_desc", errDesc));
msgGroup.addItem(mainMsg);
StandardItem msgList = new StandardItem(
"MSG_LIST", 2, StandardType.GRID, 0, 0, 0, 1, "", "", null, "MSG_LIST");
msgList.addItem(field("outp_msg_cd", ""));
msgList.addItem(field("outp_msg_ctnt", ""));
msgList.addItem(field("outp_msg_desc", ""));
msgList.addItem(field("err_occu_item_nm", ""));
if (listRows != null) {
for (String[] row : listRows) {
LinkedHashMap<String, StandardItem> rowMap = new LinkedHashMap<>();
rowMap.put("outp_msg_cd", field("outp_msg_cd", get(row, 0)));
rowMap.put("outp_msg_ctnt", field("outp_msg_ctnt", get(row, 1)));
rowMap.put("outp_msg_desc", field("outp_msg_desc", get(row, 2)));
rowMap.put("err_occu_item_nm", field("err_occu_item_nm", get(row, 3)));
msgList.addArray(rowMap);
}
}
msgGroup.addItem(msgList);
msg.addItem(msgGroup);
return msg;
}
private static String get(String[] arr, int idx) {
return (arr != null && arr.length > idx) ? arr[idx] : "";
}
private static Properties props(String... keyValues) {
Properties p = new Properties();
for (int i = 0; i < keyValues.length - 1; i += 2) {
p.setProperty(keyValues[i], keyValues[i + 1]);
}
return p;
}
// ================================================================
// 스칼라 변수 치환 ${path}
// ================================================================
@Nested
@DisplayName("StandardMessage 스칼라 변수 치환 ${path}")
class ScalarSubstitution {
@Test
@DisplayName("단순 JSON 템플릿 치환")
void 단순_치환() {
StandardMessage msg = buildMsg("E001", "오류발생", "상세설명", null);
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";
String result = handler.render(template, msg, null);
assertEquals("{\"code\":\"E001\",\"msg\":\"오류발생\"}", result);
}
@Test
@DisplayName("동일 변수 중복 치환")
void 동일_변수_중복_치환() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "${MSG.MAIN_MSG.outp_msg_cd}/${MSG.MAIN_MSG.outp_msg_cd}";
assertEquals("E001/E001", handler.render(template, msg, null));
}
@Test
@DisplayName("존재하지 않는 경로는 빈 문자열로 치환")
void 없는_경로_빈_문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${NONE.PATH}", msg, null));
}
@Test
@DisplayName("변수 없는 템플릿은 원본 그대로 반환")
void 변수_없는_템플릿_원본() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("{\"static\":\"value\"}", handler.render("{\"static\":\"value\"}", msg, null));
}
@Test
@DisplayName("outp_msg_desc 필드 치환")
void outp_msg_desc_치환() {
StandardMessage msg = buildMsg("E001", "오류", "상세설명입니다", null);
assertEquals("상세설명입니다", handler.render("${MSG.MAIN_MSG.outp_msg_desc}", msg, null));
}
@Test
@DisplayName("경로:기본값 – 경로 존재 시 값 반환")
void 표준전문_기본값_경로존재() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("E001", handler.render("${MSG.MAIN_MSG.outp_msg_cd:DEFAULT}", msg, null));
}
@Test
@DisplayName("경로:기본값 – 경로 없으면 기본값 반환")
void 표준전문_기본값_경로없음() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("UNKNOWN", handler.render("${NONE.PATH:UNKNOWN}", msg, null));
}
@Test
@DisplayName("경로:기본값 기본값이 빈 문자열")
void 표준전문_기본값_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${NONE.PATH:}", msg, null));
}
}
// ================================================================
// callProp 변수 치환 ${callprop.}
// ================================================================
@Nested
@DisplayName("callProp 변수 치환 ${callprop.키}")
class CallPropSubstitution {
@Test
@DisplayName("callProp 값을 템플릿에서 참조")
void callProp_단순_참조() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result);
}
@Test
@DisplayName("callProp 과 StandardMessage 혼합 참조")
void callProp_stdmsg_혼합() {
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"code\":\"E001\"}", result);
}
@Test
@DisplayName("callProp 에 없는 키는 빈 문자열")
void callProp_없는_키_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
assertEquals("", handler.render("${callprop.NONE_KEY}", msg, callProp));
}
@Test
@DisplayName("callProp 이 null 이면 빈 문자열")
void callProp_null_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${callprop.ADAPTER_NAME}", msg, null));
}
@Test
@DisplayName("callProp 값이 Properties 인 경우 중첩 탐색")
void callProp_중첩_Properties() {
Properties sub = new Properties();
sub.setProperty("IF_ID", "SVC001");
sub.setProperty("SVC_NM", "조회서비스");
Properties callProp = new Properties();
callProp.put("OUTBOUND", sub);
callProp.setProperty("ADAPTER_NAME", "MY_ADAPTER");
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.OUTBOUND.IF_ID}\",\"svcNm\":\"${callprop.OUTBOUND.SVC_NM}\"}";
String result = handler.render(template, msg, callProp);
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\",\"svcNm\":\"조회서비스\"}", result);
}
@Test
@DisplayName("중첩 Properties 3단계 탐색")
void callProp_3단계_중첩() {
Properties level2 = new Properties();
level2.setProperty("CODE", "L2CODE");
Properties level1 = new Properties();
level1.put("LEVEL2", level2);
Properties callProp = new Properties();
callProp.put("LEVEL1", level1);
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("L2CODE", handler.render("${callprop.LEVEL1.LEVEL2.CODE}", msg, callProp));
}
@Test
@DisplayName("중간 값이 Properties 가 아니면 전체 keyPath 로 폴백 조회")
void callProp_중간값_비Properties_폴백() {
Properties callProp = new Properties();
callProp.setProperty("A.B", "FALLBACK_VALUE"); // 포함
callProp.setProperty("A", "NOT_PROPS"); // String
StandardMessage msg = buildMsg("E001", "오류", "", null);
// A String 이므로 Properties 아님 "A.B" 전체 키로 폴백
assertEquals("FALLBACK_VALUE", handler.render("${callprop.A.B}", msg, callProp));
}
@Test
@DisplayName("중첩 Properties 에서 없는 키는 빈 문자열")
void callProp_중첩_없는키() {
Properties sub = new Properties();
sub.setProperty("EXIST", "VALUE");
Properties callProp = new Properties();
callProp.put("SUB", sub);
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("", handler.render("${callprop.SUB.NONE_KEY}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 – 키 존재 시 값 반환")
void callProp_직접_기본값_키존재() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
assertEquals("MY_ADAPTER", handler.render("${callprop.ADAPTER_NAME:DEFAULT}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 – 키 없으면 기본값 반환")
void callProp_직접_기본값_키없음() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
assertEquals("DEFAULT_VAL", handler.render("${callprop.NONE_KEY:DEFAULT_VAL}", msg, callProp));
}
@Test
@DisplayName("callprop.키:기본값 callProp null 이면 기본값 반환")
void callProp_직접_기본값_null() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("FALLBACK", handler.render("${callprop.ADAPTER_NAME:FALLBACK}", msg, null));
}
}
// ================================================================
// callProp 간접 참조 ${callprop[표준전문경로]} / ${callprop[경로]:기본값}
// ================================================================
@Nested
@DisplayName("callProp 간접 참조 ${callprop[경로]}")
class IndirectCallPropSubstitution {
/** 표준전문에 cp_key 필드가 있는 메시지 */
private StandardMessage buildMsgWithKey(String cpKey, String errCode) {
StandardMessage msg = buildMsg(errCode, "오류", "", null);
// MSG 그룹 아래 cp_key 필드 추가
StandardItem msgGroup = msg.findItem("MSG");
if (msgGroup != null) {
msgGroup.addItem(field("cp_key", cpKey));
}
return msg;
}
@Test
@DisplayName("표준전문 경로 값으로 callProp 키를 결정해 값 조회")
void 간접_참조_정상() {
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.cp_key]}";
assertEquals("MY_ADAPTER", handler.render(template, msg, callProp));
}
@Test
@DisplayName("표준전문 경로 값이 없으면 기본값 반환")
void 표준전문_경로_없으면_기본값() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.NONE_KEY]:DEFAULT_ADAPTER}";
assertEquals("DEFAULT_ADAPTER", handler.render(template, msg, callProp));
}
@Test
@DisplayName("callProp 에 해당 키 없으면 기본값 반환")
void callProp_키_없으면_기본값() {
StandardMessage msg = buildMsgWithKey("MISSING_KEY", "E001");
Properties callProp = props("OTHER_KEY", "OTHER_VALUE");
String template = "${callprop[MSG.cp_key]:FALLBACK}";
assertEquals("FALLBACK", handler.render(template, msg, callProp));
}
@Test
@DisplayName("기본값 미지정 시 빈 문자열")
void 기본값_없으면_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
String template = "${callprop[MSG.NONE_KEY]}";
assertEquals("", handler.render(template, msg, callProp));
}
@Test
@DisplayName("callProp 이 null 이면 기본값 반환")
void callProp_null_기본값() {
StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
String template = "${callprop[MSG.cp_key]:NO_PROP}";
assertEquals("NO_PROP", handler.render(template, msg, null));
}
@Test
@DisplayName("기본값이 빈 문자열인 경우")
void 기본값이_빈문자열() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
Properties callProp = new Properties();
String template = "${callprop[MSG.NONE_KEY]:}";
assertEquals("", handler.render(template, msg, callProp));
}
@Test
@DisplayName("간접 참조와 직접 참조 혼합")
void 간접_직접_혼합() {
StandardMessage msg = buildMsgWithKey("IF_ID_KEY", "E001");
Properties callProp = props("IF_ID_KEY", "SVC_001", "ADAPTER_NAME", "MY_ADAPTER");
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop[MSG.cp_key]}\"}";
assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC_001\"}", handler.render(template, msg, callProp));
}
}
// ================================================================
// 배열 반복 {{#foreach path}}...{{/foreach}}
// ================================================================
// ================================================================
// DATA 영역 변수 치환 ${DATA.xxx}
// ================================================================
@Nested
@DisplayName("DATA 영역 변수 치환 ${DATA.xxx}")
class DataSectionSubstitution {
/**
* DATA > BIZDATA(FIELD, JSON String value) 구조의 StandardMessage 생성.
* BIZDATA 파싱 없이 JSON 문자열 그대로 세팅되는 형태.
*/
private StandardMessage buildMsgWithBizData(String bizDataJson) {
StandardMessage msg = buildMsg("E001", "오류", "", null);
StandardItem dataGroup = group("DATA");
dataGroup.addItem(field("BIZDATA", bizDataJson));
msg.addItem(dataGroup);
return msg;
}
@Test
@DisplayName("DATA.BIZDATA JSON 필드 치환 단일 depth")
void DATA_BIZDATA_JSON_필드_치환() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}");
String template = "{\"acctNo\":\"${DATA.BIZDATA.acctNo}\",\"custNm\":\"${DATA.BIZDATA.custNm}\"}";
assertEquals("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}",
handler.render(template, msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 필드 치환 중첩 depth")
void DATA_BIZDATA_JSON_중첩_필드_치환() {
StandardMessage msg = buildMsgWithBizData("{\"addr\":{\"city\":\"서울\",\"zip\":\"12345\"}}");
assertEquals("서울", handler.render("${DATA.BIZDATA.addr.city}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA 원본 JSON 문자열 치환")
void DATA_BIZDATA_원본_치환() {
String rawJson = "{\"acctNo\":\"1234567890\"}";
StandardMessage msg = buildMsgWithBizData(rawJson);
assertEquals(rawJson, handler.render("${DATA.BIZDATA}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA 와 MSG 영역 혼합 치환")
void DATA_MSG_혼합_치환() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"9876543210\"}");
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"acctNo\":\"${DATA.BIZDATA.acctNo}\"}";
assertEquals("{\"code\":\"E001\",\"acctNo\":\"9876543210\"}",
handler.render(template, msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 에 없는 필드는 빈 문자열")
void DATA_BIZDATA_없는_JSON_필드_빈문자열() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
assertEquals("", handler.render("${DATA.BIZDATA.noneField}", msg, null));
}
@Test
@DisplayName("DATA.BIZDATA JSON 에 없는 필드에 기본값 지정")
void DATA_BIZDATA_없는_JSON_필드_기본값() {
StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");
assertEquals("UNKNOWN", handler.render("${DATA.BIZDATA.noneField:UNKNOWN}", msg, null));
}
@Test
@DisplayName("DATA 영역 미설정 시 기본값 반환")
void DATA_없으면_기본값() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
assertEquals("{}", handler.render("${DATA.BIZDATA:{}}", msg, null));
}
}
@Nested
@DisplayName("배열 반복 블록 {{#foreach}}")
class ForeachBlock {
@Test
@DisplayName("MSG_LIST 1건 렌더링")
void 단건_렌더링() {
StandardMessage msg = buildMsg("E001", "대표오류", "",
new String[][]{{"E001", "메시지A", "설명A", "fieldA"}});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}{{/foreach}}]";
assertEquals("[{\"c\":\"E001\",\"m\":\"메시지A\"}]", handler.render(template, msg, null));
}
@Test
@DisplayName("MSG_LIST 2건 → 쉼표로 연결")
void 다건_쉼표_연결() {
StandardMessage msg = buildMsg("E001", "대표오류", "",
new String[][]{
{"E001", "오류A", "설명A", "fieldA"},
{"E002", "오류B", "설명B", "fieldB"}
});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[{\"c\":\"E001\"},{\"c\":\"E002\"}]", handler.render(template, msg, null));
}
@Test
@DisplayName("MSG_LIST 가 비어있으면 foreach 결과는 빈 문자열")
void 빈_배열() {
StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{});
String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[]", handler.render(template, msg, null));
}
@Test
@DisplayName("foreach 경로가 없으면 빈 문자열")
void 없는_경로() {
StandardMessage msg = buildMsg("E001", "오류", "", null);
String template = "[{{#foreach NONE.LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";
assertEquals("[]", handler.render(template, msg, null));
}
@Test
@DisplayName("err_occu_item_nm 필드 포함 렌더링")
void err_occu_item_nm_포함() {
StandardMessage msg = buildMsg("E001", "오류", "",
new String[][]{{"E001", "오류A", "설명A", "accountNo"}});
String template = "{{#foreach MSG.MSG_LIST}}{\"field\":\"${err_occu_item_nm}\"}{{/foreach}}";
assertEquals("{\"field\":\"accountNo\"}", handler.render(template, msg, null));
}
}
// ================================================================
// 혼합 템플릿 (스칼라 + callProp + 배열)
// ================================================================
@Nested
@DisplayName("혼합 템플릿")
class MixedTemplate {
@Test
@DisplayName("JSON MAIN_MSG + callProp + MSG_LIST 2건")
void JSON_전체_혼합() {
StandardMessage msg = buildMsg("E001", "대표오류", "대표설명",
new String[][]{
{"E001", "오류A", "설명A", "fieldA"},
{"E002", "오류B", "설명B", "fieldB"}
});
Properties callProp = props("ADAPTER_NAME", "TEST_ADAPTER");
String template =
"{" +
"\"adapter\":\"${callprop.ADAPTER_NAME}\"," +
"\"resultCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
"\"resultMsg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"," +
"\"errors\":[{{#foreach MSG.MSG_LIST}}" +
"{\"code\":\"${outp_msg_cd}\",\"msg\":\"${outp_msg_ctnt}\",\"field\":\"${err_occu_item_nm}\"}" +
"{{/foreach}}]" +
"}";
String result = handler.render(template, msg, callProp);
assertEquals(
"{\"adapter\":\"TEST_ADAPTER\"," +
"\"resultCode\":\"E001\",\"resultMsg\":\"대표오류\"," +
"\"errors\":[{\"code\":\"E001\",\"msg\":\"오류A\",\"field\":\"fieldA\"}," +
"{\"code\":\"E002\",\"msg\":\"오류B\",\"field\":\"fieldB\"}]}",
result);
}
@Test
@DisplayName("XML 스칼라 + callProp 치환")
void XML_스칼라_callProp() {
StandardMessage msg = buildMsg("E001", "오류발생", "", null);
Properties callProp = props("IF_ID", "IF_001");
String template = "<error><ifId>${callprop.IF_ID}</ifId><code>${MSG.MAIN_MSG.outp_msg_cd}</code></error>";
assertEquals("<error><ifId>IF_001</ifId><code>E001</code></error>",
handler.render(template, msg, callProp));
}
@Test
@DisplayName("XML foreach 배열")
void XML_foreach_배열() {
StandardMessage msg = buildMsg("E001", "오류", "",
new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}});
String template =
"<errors>{{#foreach MSG.MSG_LIST}}" +
"<item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>" +
"{{/foreach}}</errors>";
assertEquals(
"<errors>" +
"<item><code>E001</code><msg>오류A</msg></item>," +
"<item><code>E002</code><msg>오류B</msg></item>" +
"</errors>",
handler.render(template, msg, null));
}
}
// ================================================================
// generateNonStandardErrorResponseMessage PropManager mock
// ================================================================
@Nested
@DisplayName("generateNonStandardErrorResponseMessage")
class GenerateMethod {
private void injectMockPropManager(PropManager mockPropManager) throws Exception {
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
@Test
@DisplayName("템플릿 미설정 시 null 반환")
void 템플릿_없으면_null() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
assertNull(result);
}
@Test
@DisplayName("빈 템플릿 시 null 반환")
void 빈_템플릿_null() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(" ");
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"TEST_GROUP", "TEST_ADAPTER", new Properties(), null, msg);
assertNull(result);
}
@Test
@DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환")
void 템플릿_stdmsg_치환() throws Exception {
String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
eq("MY_GROUP.template"))).thenReturn(template);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", new Properties(), null, msg);
assertEquals("{\"code\":\"E999\"}", result);
}
@Test
@DisplayName("callProp 값이 템플릿에 치환되어 반환")
void 템플릿_callProp_치환() throws Exception {
String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(
eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
eq("MY_GROUP.template"))).thenReturn(template);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E999", "테스트오류", "", null);
Properties callProp = props("ADAPTER_NAME", "REAL_ADAPTER");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", callProp, null, msg);
assertEquals("{\"adapter\":\"REAL_ADAPTER\",\"code\":\"E999\"}", result);
}
@Test
@DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회")
void 템플릿_키_형식_검증() throws Exception {
PropManager mockProp = Mockito.mock(PropManager.class);
Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
injectMockPropManager(mockProp);
StandardMessage msg = buildMsg("E001", "오류", "", null);
handler.generateNonStandardErrorResponseMessage(
"SAMPLE_GROUP", "ANY_ADAPTER", new Properties(), null, msg);
Mockito.verify(mockProp).getProperty(
TemplateAdapterErrorMsgHandler.PROP_GROUP,
"SAMPLE_GROUP.template");
}
}
}
@@ -0,0 +1,319 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.security.CryptoModuleService;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* BaseCryptoFilter / CryptoFilter 단위 테스트.
* CryptoModuleService는 Mock으로 대체하여 필터 로직(Base64, JSON 경로, 프로퍼티 파싱) 검증한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class BaseCryptoFilterTest {
private static final String MODULE = "TEST_MODULE";
private static final String PLAIN_STR = "decrypted-plain-text";
private static final byte[] PLAIN = PLAIN_STR.getBytes(StandardCharsets.UTF_8);
private static final byte[] CIPHER = new byte[]{0x10, 0x20, 0x30, 0x40, 0x50, 0x60};
private static final String CIPHER_B64 = Base64.getEncoder().encodeToString(CIPHER);
private static GenericApplicationContext ctx;
private static CryptoModuleService mockService;
private static BaseCryptoFilter filter;
private HttpServletRequest mockRequest;
private HttpServletResponse mockResponse;
@BeforeAll
static void setUpClass() throws Exception {
mockService = mock(CryptoModuleService.class);
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("cryptoModuleService", mockService);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
filter = new BaseCryptoFilter();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void setUp() throws Exception {
reset(mockService);
mockRequest = mock(HttpServletRequest.class);
mockResponse = mock(HttpServletResponse.class);
when(mockService.decrypt(anyString(), anyMap(), any(), any(byte[].class))).thenReturn(PLAIN);
when(mockService.encrypt(anyString(), anyMap(), any(), any(byte[].class))).thenReturn(CIPHER);
}
// =========================================================================
// 1. buildRuntimeContext
// =========================================================================
@Test
@DisplayName("1-1. buildRuntimeContext — 항상 빈 Map 반환")
void testBuildRuntimeContext_returnsEmptyMap() {
Map<String, String> result = filter.buildRuntimeContext(new Properties(), mockRequest);
assertNotNull(result);
assertTrue(result.isEmpty());
}
// =========================================================================
// 2. buildAad
// =========================================================================
@Test
@DisplayName("2-1. buildAad — CRYPTO_AAD_HEADER 미설정 → null")
void testBuildAad_noProp_returnsNull() {
assertNull(filter.buildAad(new Properties(), mockRequest));
}
@Test
@DisplayName("2-2. buildAad — 헤더명 설정, 헤더값 없음 → null")
void testBuildAad_headerNameSet_noValue_returnsNull() {
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id");
when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(null);
assertNull(filter.buildAad(prop, mockRequest));
}
@Test
@DisplayName("2-3. buildAad — 헤더명 설정, 헤더값 있음 → UTF-8 바이트 반환")
void testBuildAad_headerNameSet_valuePresent_returnsByte() {
String headerVal = "req-id-abc123";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id");
when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(headerVal);
assertArrayEquals(headerVal.getBytes(StandardCharsets.UTF_8), filter.buildAad(prop, mockRequest));
}
// =========================================================================
// 3. doPreFilter BODY scope 복호화
// =========================================================================
@Test
@DisplayName("3-1. BODY scope — Base64 암호문 복호화 → 평문 반환")
void testPreFilter_body_decrypt_roundTrip() throws Exception {
Object result = filter.doPreFilter("g", "a", CIPHER_B64, bodyDecryptProps(), mockRequest, mockResponse);
assertEquals(PLAIN_STR, result);
verify(mockService).decrypt(eq(MODULE), anyMap(), isNull(), eq(CIPHER));
}
@Test
@DisplayName("3-3. CRYPTO_MODULE_NAME 누락 — IllegalArgumentException")
void testPreFilter_missingModuleName_throwsException() {
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_SCOPE, "BODY");
assertThrows(IllegalArgumentException.class,
() -> filter.doPreFilter("g", "a", CIPHER_B64, prop, mockRequest, mockResponse));
}
@Test
@DisplayName("3-4. message가 byte[] — UTF-8 디코딩 후 처리")
void testPreFilter_body_byteArrayMessage() throws Exception {
Object result = filter.doPreFilter("g", "a", CIPHER_B64.getBytes(StandardCharsets.UTF_8),
bodyDecryptProps(), mockRequest, mockResponse);
assertEquals(PLAIN_STR, result);
}
// =========================================================================
// 4. doPreFilter FIELD scope 복호화
// =========================================================================
@Test
@DisplayName("4-1. FIELD scope — fromPath 복호화 → toPath 반영")
void testPreFilter_field_decryptToPath() throws Exception {
String body = "{\"enc\":\"" + CIPHER_B64 + "\",\"result\":\"\"}";
Properties prop = fieldDecryptProps("/enc", "/result");
Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse);
assertTrue(result.toString().contains("\"result\":\"" + PLAIN_STR + "\""),
"toPath에 복호화된 값이 반영되어야 한다");
verify(mockService).decrypt(eq(MODULE), anyMap(), isNull(), eq(CIPHER));
}
@Test
@DisplayName("4-2. FIELD scope, toPath='/' — 복호화된 텍스트로 body 전체 교체")
void testPreFilter_field_decryptToRoot() throws Exception {
String body = "{\"enc\":\"" + CIPHER_B64 + "\"}";
Properties prop = fieldDecryptProps("/enc", "/");
Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse);
assertEquals(PLAIN_STR, result, "toPath='/'이면 body 전체가 복호화된 텍스트로 교체되어야 한다");
}
@Test
@DisplayName("4-3. FIELD scope — fromPath 값 없음 → body 그대로 반환")
void testPreFilter_field_missingFromPathValue_passThrough() throws Exception {
String body = "{\"other\":\"value\"}";
Properties prop = fieldDecryptProps("/enc", "/other");
Object result = filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse);
assertEquals(body, result);
verifyNoInteractions(mockService);
}
// =========================================================================
// 5. doPostFilter BODY scope 암호화
// =========================================================================
@Test
@DisplayName("5-1. BODY scope, CRYPTO_ENC_ENABLED=Y — 평문 암호화 → Base64 반환")
void testPostFilter_body_encrypt() throws Exception {
String body = "plain-response-body";
Object result = filter.doPostFilter("g", "a", body, bodyEncryptProps(), mockRequest, mockResponse);
assertEquals(CIPHER_B64, result);
verify(mockService).encrypt(eq(MODULE), anyMap(), isNull(), eq(body.getBytes(StandardCharsets.UTF_8)));
}
// =========================================================================
// 6. doPostFilter FIELD scope 암호화
// =========================================================================
@Test
@DisplayName("6-1. FIELD scope — fromPath 평문 암호화 → toPath Base64 반영")
void testPostFilter_field_encryptToPath() throws Exception {
String body = "{\"plain\":\"hello\",\"enc\":\"\"}";
Properties prop = fieldEncryptProps("/plain", "/enc");
Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse);
assertTrue(result.toString().contains("\"enc\":\"" + CIPHER_B64 + "\""),
"toPath에 Base64 암호문이 반영되어야 한다");
}
@Test
@DisplayName("6-2. FIELD scope, fromPath='/' — body 전체 암호화 → toPath 필드명으로 래핑")
void testPostFilter_field_encryptFromRoot() throws Exception {
String body = "{\"plain\":\"hello\"}";
Properties prop = fieldEncryptProps("/", "/encrypted");
Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse);
assertEquals("{\"encrypted\":\"" + CIPHER_B64 + "\"}", result);
}
@Test
@DisplayName("6-3. FIELD scope — fromPath 값 없음 → body 그대로 반환")
void testPostFilter_field_missingFromPathValue_passThrough() throws Exception {
String body = "{\"other\":\"value\"}";
Properties prop = fieldEncryptProps("/plain", "/enc");
Object result = filter.doPostFilter("g", "a", body, prop, mockRequest, mockResponse);
assertEquals(body, result);
verifyNoInteractions(mockService);
}
// =========================================================================
// 7. AAD 헤더 연동
// =========================================================================
@Test
@DisplayName("7-1. doPreFilter BODY — CRYPTO_AAD_HEADER 설정 시 헤더값을 AAD로 전달")
void testPreFilter_body_aadFromHeader_passedToService() throws Exception {
String aadVal = "request-id-xyz";
Properties prop = bodyDecryptProps();
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Request-Id");
when(mockRequest.getHeader("X-Api-Request-Id")).thenReturn(aadVal);
filter.doPreFilter("g", "a", CIPHER_B64, prop, mockRequest, mockResponse);
verify(mockService).decrypt(eq(MODULE), anyMap(),
eq(aadVal.getBytes(StandardCharsets.UTF_8)), eq(CIPHER));
}
@Test
@DisplayName("7-2. doPostFilter BODY — CRYPTO_AAD_HEADER 미설정 시 aad=null로 전달")
void testPostFilter_body_noAadHeader_nullAadPassedToService() throws Exception {
filter.doPostFilter("g", "a", "plain", bodyEncryptProps(), mockRequest, mockResponse);
verify(mockService).encrypt(eq(MODULE), anyMap(), isNull(), any(byte[].class));
}
@Test
@DisplayName("7-3. doPreFilter FIELD — CRYPTO_AAD_HEADER 설정 시 헤더값을 AAD로 전달")
void testPreFilter_field_aadFromHeader_passedToService() throws Exception {
String aadVal = "field-aad-value";
String body = "{\"enc\":\"" + CIPHER_B64 + "\",\"result\":\"\"}";
Properties prop = fieldDecryptProps("/enc", "/result");
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Api-Aad");
when(mockRequest.getHeader("X-Api-Aad")).thenReturn(aadVal);
filter.doPreFilter("g", "a", body, prop, mockRequest, mockResponse);
verify(mockService).decrypt(eq(MODULE), anyMap(),
eq(aadVal.getBytes(StandardCharsets.UTF_8)), eq(CIPHER));
}
// =========================================================================
// 헬퍼
// =========================================================================
private Properties bodyDecryptProps() {
Properties p = new Properties();
p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE);
p.setProperty(CryptoFilter.PROP_SCOPE, "BODY");
return p;
}
private Properties bodyEncryptProps() {
return bodyDecryptProps();
}
private Properties fieldDecryptProps(String fromPath, String toPath) {
Properties p = new Properties();
p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE);
p.setProperty(CryptoFilter.PROP_SCOPE, "FIELD");
p.setProperty(CryptoFilter.PROP_DEC_FROM_PATH, fromPath);
p.setProperty(CryptoFilter.PROP_DEC_TO_PATH, toPath);
return p;
}
private Properties fieldEncryptProps(String fromPath, String toPath) {
Properties p = new Properties();
p.setProperty(CryptoFilter.PROP_MODULE_NAME, MODULE);
p.setProperty(CryptoFilter.PROP_SCOPE, "FIELD");
p.setProperty(CryptoFilter.PROP_ENC_FROM_PATH, fromPath);
p.setProperty(CryptoFilter.PROP_ENC_TO_PATH, toPath);
return p;
}
}
@@ -0,0 +1,464 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.hsm.HsmManager;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.security.CryptoModuleConfigVO;
import com.eactive.eai.common.security.CryptoModuleManager;
import com.eactive.eai.common.security.CryptoModuleService;
import com.eactive.eai.common.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy;
import com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
/**
* CryptoFilter HSM 연동 통합 테스트 (SoftHSM2 / SunPKCS11)
*
* SoftHSM2에서 MASTER_KEY를 조회하여 키를 도출한
* BaseCryptoFilter / CryptoFilter 암복호화 전체 흐름을 검증한다.
*
* 사전 조건:
* C:\SoftHSM2 SoftHSM2 설치 (미설치 자동 건너뜀)
* MASTER_KEY alias는 없으면 자동 생성됨
*
* 검증 전략:
* HSM 도출 CryptoModuleExtension CryptoFilter doPostFilter(암호화) doPreFilter(복호화) 원문 일치
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
public class CryptoFilterHsmIntegrationTest {
// -------------------------------------------------------------------------
// 상수
// -------------------------------------------------------------------------
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String MASTER_KEY_ALIAS = "MASTER_KEY";
/** HsmKeyDerivationStrategy: HSM 마스터키를 파생 없이 직접 사용 */
private static final String MOD_HSM_GCM = "HSM_GCM";
/** HsmContextSha256KeyDerivationStrategy: HSM 마스터키 + 컨텍스트값 SHA-256 파생 */
private static final String MOD_CTX_SHA_GCM = "CTX_SHA256_GCM";
private static final String CTX_KEY = "X-Api-Group-Seq";
private static final String CTX_VAL_A = "GROUP-A";
private static final String CTX_VAL_B = "GROUP-B";
// -------------------------------------------------------------------------
// 정적 필드
// -------------------------------------------------------------------------
private static GenericApplicationContext springCtx;
private static HsmManager hsmManager;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 내부 클래스: runtimeContext를 고정값으로 반환하는 필터
// -------------------------------------------------------------------------
static class ContextAwareCryptoFilter extends CryptoFilter {
private final String key;
private final String value;
ContextAwareCryptoFilter(String key, String value) {
this.key = key;
this.value = value;
}
@Override
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
Map<String, String> ctx = new HashMap<>();
ctx.put(key, value);
return ctx;
}
}
// -------------------------------------------------------------------------
// 인스턴스 필드
// -------------------------------------------------------------------------
private HttpServletRequest mockReq;
private HttpServletResponse mockRes;
// -------------------------------------------------------------------------
// 전체 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpAll() throws Exception {
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
// 1. 토큰 초기화
ensureTokenInitialized();
// 2. PKCS11 설정 문자열
// attributes(*,...): generate/import 모두 CKA_SENSITIVE=false getEncoded() 사용 가능
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL + "\n"
+ "slotListIndex = 0\n"
+ "attributes(*, CKO_SECRET_KEY, CKK_AES) = {\n"
+ " CKA_SENSITIVE = false\n"
+ " CKA_EXTRACTABLE = true\n"
+ "}\n";
tempCfgFile = File.createTempFile("pkcs11-filter-it-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. Mock PropManager (DB 없이 HSM 설정값 제공)
PropManager mockPropManager = mock(PropManager.class);
when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent);
when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN);
when(mockPropManager.getProperty(eq("HSM"), eq("CACHE_RELOAD_YN"), anyString())).thenReturn("N");
// 4. HsmManager (private 생성자 리플렉션)
Constructor<HsmManager> hsmCtor = HsmManager.class.getDeclaredConstructor();
hsmCtor.setAccessible(true);
hsmManager = hsmCtor.newInstance();
// 5. CryptoModuleManager (private 생성자 리플렉션)
CryptoModuleConfigLoader mockLoader = mock(CryptoModuleConfigLoader.class);
when(mockLoader.findAll()).thenReturn(buildModuleConfigs());
Constructor<CryptoModuleManager> managerCtor = CryptoModuleManager.class.getDeclaredConstructor();
managerCtor.setAccessible(true);
CryptoModuleManager manager = managerCtor.newInstance();
CryptoModuleService cryptoService = new CryptoModuleService();
HsmCryptoService hsmCryptoService = new HsmCryptoService();
// 6. Spring ApplicationContext 구성
springCtx = new GenericApplicationContext();
springCtx.getBeanFactory().registerSingleton("propManager", mockPropManager);
springCtx.getBeanFactory().registerSingleton("hsmManager", hsmManager);
springCtx.getBeanFactory().registerSingleton("hsmCryptoService", hsmCryptoService);
springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
springCtx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
springCtx.getBeanFactory().registerSingleton("cryptoModuleService", cryptoService);
springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
springCtx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
springCtx.refresh();
// 7. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈)
hsmManager.start();
assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패");
System.out.println("[HSM] Provider: " + hsmManager.getPkcs11Provider().getName());
// 8. MASTER_KEY 등록 (없으면 자동 생성)
ensureMasterKey();
// 9. CryptoModuleManager 시작 (모듈 설정 로드)
manager.start();
System.out.println("[Crypto] 모듈 로드 완료");
}
@AfterAll
static void tearDownAll() throws Exception {
if (hsmManager != null && hsmManager.isStarted()) hsmManager.stop();
if (springCtx != null) springCtx.close();
if (tempCfgFile != null) tempCfgFile.delete();
}
@BeforeEach
void setUp() {
mockReq = mock(HttpServletRequest.class);
mockRes = mock(HttpServletResponse.class);
}
// =========================================================================
// 1. HsmKeyDerivationStrategy HSM 마스터키 직접 사용 (BaseCryptoFilter)
// =========================================================================
@Test
@DisplayName("1-1. FIELD/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립")
void testHsmGcm_field_roundTrip() throws Exception {
BaseCryptoFilter filter = new BaseCryptoFilter();
String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
// SCOPE 기본값 FIELD, 경로 기본값 사용
// 암호화: body 전체 /encrypted_data 필드
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[1-1] encrypted: " + encrypted);
assertTrue(encrypted.contains("encrypted_data"), "/encrypted_data 필드가 있어야 한다");
// 복호화: /encrypted_data 필드 body 전체 교체
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-1] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-2. BODY/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립")
void testHsmGcm_body_roundTrip() throws Exception {
BaseCryptoFilter filter = new BaseCryptoFilter();
String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(CryptoFilter.PROP_SCOPE, "BODY");
// 암호화: body 전체 Base64 암호문
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[1-2] encrypted(Base64): " + encrypted);
assertNotEquals(original, encrypted, "암호문은 평문과 달라야 한다");
// 복호화: Base64 암호문 원문
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-2] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-3. FIELD/GCM HsmKey + AAD 헤더 — 동일 요청 ID로 암복호화 성공")
void testHsmGcm_field_withAad_success() throws Exception {
BaseCryptoFilter filter = new BaseCryptoFilter();
String original = "{\"amount\":\"500000\"}";
String requestId = "REQ-20240101-001";
when(mockReq.getHeader("X-Request-Id")).thenReturn(requestId);
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Request-Id");
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
String decrypted = (String) filter.doPreFilter( "G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-3] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-4. FIELD/GCM HsmKey + AAD 헤더 — 다른 요청 ID로 복호화 시 GCM 인증 실패")
void testHsmGcm_field_withAad_wrongAad_fails() throws Exception {
BaseCryptoFilter filter = new BaseCryptoFilter();
String original = "{\"amount\":\"500000\"}";
// 암호화 요청 ID
HttpServletRequest encReq = mock(HttpServletRequest.class);
when(encReq.getHeader("X-Request-Id")).thenReturn("REQ-CORRECT");
// 복호화 다른 요청 ID
HttpServletRequest decReq = mock(HttpServletRequest.class);
when(decReq.getHeader("X-Request-Id")).thenReturn("REQ-WRONG");
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(CryptoFilter.PROP_AAD_HEADER, "X-Request-Id");
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, encReq, mockRes);
assertThrows(Exception.class,
() -> filter.doPreFilter("G", "A", encrypted, prop, decReq, mockRes),
"잘못된 AAD로 GCM 복호화 시 예외가 발생해야 한다");
System.out.println("[1-4] 다른 AAD 복호화 예외 확인 완료");
}
// =========================================================================
// 2. HsmContextSha256KeyDerivationStrategy 컨텍스트 기반 SHA-256 파생
// =========================================================================
@Test
@DisplayName("2-1. FIELD/GCM ContextSha256 — 동일 컨텍스트로 암복호화 라운드트립")
void testCtxSha256Gcm_field_roundTrip() throws Exception {
CryptoFilter filter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
String original = "{\"accNo\":\"0011234567890\",\"amount\":\"50000\"}";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[2-1] encrypted: " + encrypted);
assertTrue(encrypted.contains("encrypted_data"));
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[2-1] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("2-2. FIELD/GCM ContextSha256 — 암호화와 다른 컨텍스트로 복호화 시 GCM 인증 실패")
void testCtxSha256Gcm_differentContext_throwsException() throws Exception {
CryptoFilter encFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
CryptoFilter decFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B);
String original = "{\"data\":\"sensitive\"}";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encrypted = (String) encFilter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
assertThrows(Exception.class,
() -> decFilter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes),
"다른 컨텍스트 키로 파생된 키는 복호화에 실패해야 한다");
System.out.println("[2-2] 다른 컨텍스트 복호화 예외 확인 완료");
}
@Test
@DisplayName("2-3. FIELD/GCM ContextSha256 — 컨텍스트별 독립 암복호화 (GROUP-A, GROUP-B 각각 성공)")
void testCtxSha256Gcm_perGroupRoundTrip() throws Exception {
CryptoFilter filterA = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
CryptoFilter filterB = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B);
String plainA = "{\"group\":\"A\",\"amount\":\"1000\"}";
String plainB = "{\"group\":\"B\",\"amount\":\"2000\"}";
Properties prop = new Properties();
prop.setProperty(CryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encA = (String) filterA.doPostFilter("G", "A", plainA, prop, mockReq, mockRes);
String encB = (String) filterB.doPostFilter("G", "B", plainB, prop, mockReq, mockRes);
assertEquals(plainA, (String) filterA.doPreFilter("G", "A", encA, prop, mockReq, mockRes));
assertEquals(plainB, (String) filterB.doPreFilter("G", "B", encB, prop, mockReq, mockRes));
System.out.println("[2-3] GROUP-A, GROUP-B 각각 독립 암복호화 성공");
}
// =========================================================================
// 헬퍼
// =========================================================================
private static List<CryptoModuleConfig> buildModuleConfigs() {
return Arrays.asList(
buildDynamic(MOD_HSM_GCM, "AES", "GCM", "NoPadding",
HsmKeyDerivationStrategy.class.getName(),
"{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\"}"),
buildDynamic(MOD_CTX_SHA_GCM, "AES", "GCM", "NoPadding",
HsmContextSha256KeyDerivationStrategy.class.getName(),
"{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\",\"contextKey\":\"" + CTX_KEY + "\"}")
);
}
private static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding,
String strategy, String params) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setKeySourceType("DYNAMIC");
c.setKeyDerivStrategy(strategy);
c.setKeyDerivParams(params);
c.setCacheYn("Y");
c.setCacheTtlSec(60);
c.setUseYn("Y");
// ivHex = null: GCM에서 IV를 AAD 대체값으로 사용하지 않도록 의도적으로 미설정
return c;
}
private static void ensureMasterKey() throws Exception {
java.security.KeyStore ks = hsmManager.getKeyStore();
// 이전 실행 삭제 (sensitive 여부 무관하게 항상 고정키로 재등록)
if (ks.containsAlias(MASTER_KEY_ALIAS)) {
ks.deleteEntry(MASTER_KEY_ALIAS);
System.out.println("[HSM] 기존 MASTER_KEY 삭제");
}
// CryptoModuleServiceTest.KEY_128 동일한 고정 테스트 암호문 비교 가능
// attributes(*, CKO_SECRET_KEY, CKK_AES) 지시어로 임포트 CKA_SENSITIVE=false 적용
byte[] keyBytes = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
SecretKey fixedKey = new SecretKeySpec(keyBytes, "AES");
ks.setKeyEntry(MASTER_KEY_ALIAS, fixedKey, null, null);
// 임포트 getEncoded() 검증
SecretKey stored = (SecretKey) ks.getKey(MASTER_KEY_ALIAS, null);
byte[] encoded = stored.getEncoded();
System.out.println("[HSM] MASTER_KEY 등록 완료: alias=" + MASTER_KEY_ALIAS
+ " / getEncoded()=" + (encoded != null ? encoded.length + "bytes, " + new String(encoded) : "null (추출 불가!)"));
}
private static void ensureTokenInitialized() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL, "--pin", PIN, "--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
System.out.println("[SoftHSM2] init-token: " + out.trim());
}
private static CryptoModuleConfigMapper testMapper() {
return new CryptoModuleConfigMapper() {
@Override
public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
vo.setCryptoId(e.getCryptoId());
vo.setCryptoName(e.getCryptoName());
vo.setCryptoDesc(e.getCryptoDesc());
vo.setAlgType(e.getAlgType());
vo.setCipherMode(e.getCipherMode());
vo.setPadding(e.getPadding());
vo.setIvHex(e.getIvHex());
vo.setKeySourceType(e.getKeySourceType());
vo.setEncKeyHex(e.getEncKeyHex());
vo.setDecKeyHex(e.getDecKeyHex());
vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
vo.setKeyDerivParams(e.getKeyDerivParams());
vo.setCacheYn(e.getCacheYn());
vo.setCacheTtlSec(e.getCacheTtlSec());
vo.setUseYn(e.getUseYn());
return vo;
}
@Override
public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
return null;
}
};
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -0,0 +1,103 @@
package com.eactive.eai.agent.inflow;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* ReloadInflowClientControlCommand 단위 테스트.
*
* <p>InflowControlUtil의 cachedInflowControlManager 필드에 mock을 직접 주입하여
* PropManager/ApplicationContext 없이 동작을 검증한다.
*/
class ReloadInflowClientControlCommandTest {
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@AfterEach
void tearDown() {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
private ReloadInflowClientControlCommand commandWith(Object args) {
ReloadInflowClientControlCommand cmd = new ReloadInflowClientControlCommand();
cmd.setArgs(args);
return cmd;
}
// =========================================================================
// args 타입 검증 실패
// =========================================================================
@Test
void execute_argsIsInteger_throwsCommandException() {
// args 검증이 getInflowControlManager() 호출 이전에 발생하므로 캐시 설정 불필요
assertThrows(CommandException.class, commandWith(Integer.valueOf(1))::execute);
}
// =========================================================================
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
assertThrows(CommandException.class, commandWith("CLIENT_A")::execute);
}
// =========================================================================
// 전체 재로드 (ALL)
// =========================================================================
@Test
void execute_argsIsALL_callsReloadClientNoArgs() throws Exception {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
Object result = commandWith("ALL").execute();
assertEquals("success", result);
verify(mockDualManager).reloadClient();
verify(mockDualManager, never()).reloadClient(anyString());
}
// =========================================================================
// 개별 재로드 (ClientId)
// =========================================================================
@Test
void execute_argsIsClientId_callsReloadClientWithId() throws Exception {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
Object result = commandWith("CLIENT_A").execute();
assertEquals("success", result);
verify(mockDualManager).reloadClient("CLIENT_A");
verify(mockDualManager, never()).reloadClient();
}
@Test
void execute_differentClientId_callsReloadClientWithThatId() throws Exception {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
commandWith("CLIENT_B").execute();
verify(mockDualManager).reloadClient("CLIENT_B");
}
}
@@ -0,0 +1,96 @@
package com.eactive.eai.agent.inflow;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.inflow.InflowControlManager;
import com.eactive.eai.common.inflow.InflowControlUtil;
import com.eactive.eai.common.inflow.dual.ClientDualInflowControlManager;
/**
* RemoveInflowClientControlCommand 단위 테스트.
*
* <p>InflowControlUtil의 cachedInflowControlManager 필드에 mock을 직접 주입하여
* PropManager/ApplicationContext 없이 동작을 검증한다.
*/
class RemoveInflowClientControlCommandTest {
private ClientDualInflowControlManager mockDualManager;
@BeforeEach
void setUp() {
mockDualManager = mock(ClientDualInflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
@AfterEach
void tearDown() {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", null);
}
private RemoveInflowClientControlCommand commandWith(Object args) {
RemoveInflowClientControlCommand cmd = new RemoveInflowClientControlCommand();
cmd.setArgs(args);
return cmd;
}
// =========================================================================
// args 타입 검증 실패
// =========================================================================
@Test
void execute_argsIsInteger_throwsCommandException() {
assertThrows(CommandException.class, commandWith(Integer.valueOf(1))::execute);
}
// =========================================================================
// ClientDualInflowControlManager 비활성
// =========================================================================
@Test
void execute_notClientDualManager_throwsCommandException() {
InflowControlManager baseMgr = mock(InflowControlManager.class);
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", baseMgr);
assertThrows(CommandException.class, commandWith("CLIENT_A")::execute);
}
// =========================================================================
// 정상 제거
// =========================================================================
@Test
void execute_validClientId_callsRemoveClient() throws CommandException {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
Object result = commandWith("CLIENT_A").execute();
assertEquals("success", result);
verify(mockDualManager).removeClient("CLIENT_A");
}
@Test
void execute_differentClientId_callsRemoveClientWithThatId() throws Exception {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
commandWith("CLIENT_B").execute();
verify(mockDualManager).removeClient("CLIENT_B");
}
@Test
void execute_validClientId_doesNotCallReload() throws Exception {
ReflectionTestUtils.setField(InflowControlUtil.class, "cachedInflowControlManager", mockDualManager);
commandWith("CLIENT_A").execute();
verify(mockDualManager, never()).reloadClient();
verify(mockDualManager, never()).reloadClient(anyString());
}
}
@@ -0,0 +1,450 @@
package com.eactive.eai.common.circuitBreaker.type;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
/**
* TypedCircuitBreakerManager 단위 테스트
*
* DB / Spring 컨텍스트 없이 동작한다.
* - PropManager : Mockito Mock (DB 없이 설정값 반환)
* - TypedCircuitBreakerDAO : Mockito Mock (JPA 없이 VO 반환)
* - Spring Bean : GenericApplicationContext 수동 구성
*
* 테스트 격리:
* 테스트는 고유한 apiId 사용하거나 manager.reload() 레지스트리를 초기화한다.
* manager.reload() CircuitBreakerRegistry 새로 생성하므로 이전 상태가 제거된다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class TypedCircuitBreakerManagerTest {
// 슬라이딩 윈도우·최소 호출을 4로 설정해 빠른 상태 전이를 유도한다.
private static final int DEFAULT_FAILURE_RATE = 50;
private static final int DEFAULT_SLIDING_WINDOW = 4;
private static final int DEFAULT_MIN_CALLS = 4;
private static final int DEFAULT_WAIT_DURATION_SEC = 1; // OPEN 대기 1초 (테스트용)
private static final int DEFAULT_HALF_OPEN_PERMITS = 2;
private static final int DEFAULT_SLOW_RATE = 100;
private static final int DEFAULT_SLOW_DURATION_SEC = 5;
private static GenericApplicationContext ctx;
private static PropManager mockPropManager;
private static TypedCircuitBreakerDAO mockDao;
private static TypedCircuitBreakerManager manager;
// -------------------------------------------------------------------------
// 픽스처 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpClass() throws Exception {
mockPropManager = mock(PropManager.class);
mockDao = mock(TypedCircuitBreakerDAO.class);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
// TypedCircuitBreakerManager 인스턴스 생성 DAO 리플렉션 주입
manager = new TypedCircuitBreakerManager();
injectField(manager, "dao", mockDao);
// DB / Spring 없이 최소 컨텍스트 구성
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
ctx.getBeanFactory().registerSingleton("typedCircuitBreakerManager", manager);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
manager.start();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void resetMocks() throws Exception {
reset(mockDao);
setupDefaultPropManagerMock();
when(mockDao.getList()).thenReturn(Collections.emptyList());
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void setupDefaultPropManagerMock() {
when(mockPropManager.getProperty("CircuitBreaker", "failureRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_FAILURE_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slidingWindowSize"))
.thenReturn(String.valueOf(DEFAULT_SLIDING_WINDOW));
when(mockPropManager.getProperty("CircuitBreaker", "minimumNumberOfCalls"))
.thenReturn(String.valueOf(DEFAULT_MIN_CALLS));
when(mockPropManager.getProperty("CircuitBreaker", "waitDurationInOpenState"))
.thenReturn(String.valueOf(DEFAULT_WAIT_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "permittedNumberOfCallsInHalfOpenState"))
.thenReturn(String.valueOf(DEFAULT_HALF_OPEN_PERMITS));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallRateThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_RATE));
when(mockPropManager.getProperty("CircuitBreaker", "slowCallDurationThreshold"))
.thenReturn(String.valueOf(DEFAULT_SLOW_DURATION_SEC));
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("1");
}
private static void injectField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/** 테스트용 TypedCircuitBreakerVO 생성 (기본 설정값 사용) */
private TypedCircuitBreakerVO buildVo(String id, String apiId, String useYn) {
return TypedCircuitBreakerVO.builder()
.id(id).name(apiId).type("COUNT_BASED").desc(apiId + " 테스트 설정")
.failureRateThreshold(DEFAULT_FAILURE_RATE)
.slidingWindowSize(DEFAULT_SLIDING_WINDOW)
.minimumNumberOfCalls(DEFAULT_MIN_CALLS)
.waitDurationInOpenState(DEFAULT_WAIT_DURATION_SEC)
.permittedNumberOfCallsInHalfOpenState(DEFAULT_HALF_OPEN_PERMITS)
.slowCallRateThreshold(DEFAULT_SLOW_RATE)
.slowCallDurationThreshold(DEFAULT_SLOW_DURATION_SEC)
.useYn(useYn)
.build();
}
/** CB를 count 회 실패 처리한다 */
private void recordFailures(CircuitBreaker cb, int count) {
for (int i = 0; i < count; i++) {
try {
cb.executeCallable(() -> { throw new RuntimeException("테스트 강제 오류"); });
} catch (Exception ignored) {}
}
}
// =========================================================================
// 1. getCircuitBreaker - 기본 조회 동작
// =========================================================================
@Test
@DisplayName("1-1. 등록된 apiId → 해당 설정의 CircuitBreaker 반환")
void testGetCircuitBreaker_registeredApiId_returnsTypedCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-001", "API_REG", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_REG");
assertNotNull(cb);
assertEquals("API_REG", cb.getName());
}
@Test
@DisplayName("1-2. 미등록 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_unregisteredApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_UNKNOWN");
assertNotNull(cb, "미등록 apiId 는 default CB 를 반환해야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-3. useYn=0 인 apiId → default CircuitBreaker 로 fallback")
void testGetCircuitBreaker_disabledApiId_returnsDefaultCircuitBreaker() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-002", "API_DISABLED", "0")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_DISABLED");
assertNotNull(cb, "비활성 apiId 는 default CB 로 fallback 되어야 한다");
assertEquals("default", cb.getName());
}
@Test
@DisplayName("1-4. apiId 도 default 도 비활성(useYn=0) → null 반환")
void testGetCircuitBreaker_defaultDisabled_returnsNull() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("0");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNull(cb, "default 도 비활성이면 null 을 반환해야 한다");
}
@Test
@DisplayName("1-5. PropManager useYn=Y → normalizeUseYn 으로 '1' 정규화 → default 활성")
void testGetCircuitBreaker_defaultUseYn_Y_isNormalizedToEnabled() throws Exception {
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("Y");
when(mockDao.getList()).thenReturn(Collections.emptyList());
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
assertNotNull(cb, "PropManager useYn=Y 는 활성으로 정규화되어야 한다");
assertEquals("default", cb.getName());
}
// =========================================================================
// 2. apiId 독립성
// =========================================================================
@Test
@DisplayName("2-1. 서로 다른 apiId → 독립적인 CircuitBreaker 인스턴스 반환")
void testDifferentApiIds_haveIndependentCircuitBreakerInstances() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-A", "API_INDEP_A", "1"),
buildVo("uuid-B", "API_INDEP_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_INDEP_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_INDEP_B");
assertNotNull(cbA);
assertNotNull(cbB);
assertNotSame(cbA, cbB, "서로 다른 apiId 는 다른 CB 인스턴스여야 한다");
assertEquals("API_INDEP_A", cbA.getName());
assertEquals("API_INDEP_B", cbB.getName());
}
@Test
@DisplayName("2-2. API_A 실패 → OPEN, API_B 는 CLOSED 유지 (상태 격리)")
void testDifferentApiIds_statesAreIsolated() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-ISO-A", "API_ISO_A", "1"),
buildVo("uuid-ISO-B", "API_ISO_B", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_ISO_A");
CircuitBreaker cbB = manager.getCircuitBreaker("API_ISO_B");
// API_ISO_A minimumNumberOfCalls=4 실패
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "API_ISO_A 는 OPEN 이어야 한다");
assertEquals(CircuitBreaker.State.CLOSED, cbB.getState(), "API_ISO_B 는 CLOSED 를 유지해야 한다");
}
// =========================================================================
// 3. 상태 전이
// =========================================================================
@Test
@DisplayName("3-1. 실패율 50% 초과(4/4 실패) → CLOSED → OPEN")
void testCircuitBreaker_closedToOpen_afterFailureThresholdExceeded() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-001", "API_ST_OPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_OPEN");
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "초기 상태는 CLOSED 여야 한다");
// minimumNumberOfCalls=4, failureRateThreshold=50% 4/4 실패(100%) OPEN
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(), "실패율 초과 시 OPEN 상태여야 한다");
}
@Test
@DisplayName("3-2. 실패율 미달(1/4 실패, 25%) → CLOSED 유지")
void testCircuitBreaker_belowFailureThreshold_remainsClosed() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-002", "API_ST_BELOW", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BELOW");
// 4회 3회 성공, 1회 실패 실패율 25% < 50% CLOSED 유지
// Resilience4j >= 비교: 50% >= 50% OPEN 이므로 25% 낮춰야 CLOSED 유지
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
recordFailures(cb, 1);
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "실패율 25% (< threshold 50%) 이면 CLOSED 를 유지해야 한다");
}
@Test
@DisplayName("3-3. CB OPEN 상태 → 모든 요청 즉시 차단 (CallNotPermittedException)")
void testCircuitBreaker_open_blocksAllRequests() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-003", "API_ST_BLOCK", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BLOCK");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
assertThrows(CallNotPermittedException.class,
() -> cb.executeCallable(() -> "도달하면 안 됨"),
"OPEN 상태에서 CallNotPermittedException 이 발생해야 한다");
}
@Test
@DisplayName("3-4. OPEN → waitDuration(1초) 경과 → 첫 요청 시 HALF_OPEN 전이")
void testCircuitBreaker_open_transitionsToHalfOpenAfterWaitDuration() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-004", "API_ST_HALFOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_HALFOPEN");
recordFailures(cb, 4);
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
// waitDurationInOpenState = 1초 대기 요청 HALF_OPEN 전이
Thread.sleep(1200);
try { cb.executeCallable(() -> "HALF_OPEN 트리거"); } catch (Exception ignored) {}
assertEquals(CircuitBreaker.State.HALF_OPEN, cb.getState(),
"waitDuration 경과 후 첫 요청 시 HALF_OPEN 상태여야 한다");
}
@Test
@DisplayName("3-5. HALF_OPEN → 성공(permittedCalls=2회) → CLOSED 복구")
void testCircuitBreaker_halfOpen_transitionsToClosedOnSuccess() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-005", "API_ST_RECOVER", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_RECOVER");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 성공
for (int i = 0; i < DEFAULT_HALF_OPEN_PERMITS; i++) {
final String result = cb.executeCallable(() -> "성공");
assertEquals("성공", result);
}
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(),
"HALF_OPEN 에서 허용 호출 수 이상 성공하면 CLOSED 로 복구되어야 한다");
}
@Test
@DisplayName("3-6. HALF_OPEN → 실패 → 다시 OPEN 재전이")
void testCircuitBreaker_halfOpen_transitionsBackToOpenOnFailure() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-006", "API_ST_REOPEN", "1")));
manager.reload();
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_REOPEN");
recordFailures(cb, 4);
Thread.sleep(1200);
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 모두 실패해야 OPEN 으로 재전이된다.
// Resilience4j 허용 호출 (2회) 모두 소진한 후에 실패율을 평가하므로
// 1회만 실패하면 HALF_OPEN 상태가 유지된다.
recordFailures(cb, DEFAULT_HALF_OPEN_PERMITS);
assertEquals(CircuitBreaker.State.OPEN, cb.getState(),
"HALF_OPEN 에서 허용 호출 수(2회) 모두 실패하면 다시 OPEN 으로 전이되어야 한다");
}
// =========================================================================
// 4. reload
// =========================================================================
@Test
@DisplayName("4-1. reload(key) → CircuitBreakerConfig 갱신 (waitDuration 1초 → 60초)")
void testReload_key_updatesCircuitBreakerConfig() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-001", "API_RELOAD", "1")));
manager.reload();
// waitDurationInOpenState 60초로 변경
TypedCircuitBreakerVO updated = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-001").name("API_RELOAD").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(60)
.permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("1").build();
when(mockDao.get("uuid-RLD-001")).thenReturn(updated);
manager.reload("uuid-RLD-001");
CircuitBreaker cb = manager.getCircuitBreaker("API_RELOAD");
assertEquals(60,
cb.getCircuitBreakerConfig().getWaitDurationInOpenState().getSeconds(),
"reload 후 waitDurationInOpenState 가 60초로 갱신되어야 한다");
}
@Test
@DisplayName("4-2. reload(key) useYn=0 → 맵에서 제거 → default fallback")
void testReload_key_disabledVo_removesFromMap() throws Exception {
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-002", "API_RM", "1")));
manager.reload();
assertEquals("API_RM", manager.getCircuitBreaker("API_RM").getName());
// useYn=0 으로 비활성화
TypedCircuitBreakerVO disabled = TypedCircuitBreakerVO.builder()
.id("uuid-RLD-002").name("API_RM").type("COUNT_BASED")
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
.waitDurationInOpenState(1).permittedNumberOfCallsInHalfOpenState(2)
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
.useYn("0").build();
when(mockDao.get("uuid-RLD-002")).thenReturn(disabled);
manager.reload("uuid-RLD-002");
CircuitBreaker cb = manager.getCircuitBreaker("API_RM");
assertEquals("default", cb.getName(),
"비활성화된 CB 는 맵에서 제거 후 default 로 fallback 되어야 한다");
}
@Test
@DisplayName("4-3. reload() 전체 → CircuitBreakerRegistry 재생성으로 이전 CB 상태 초기화")
void testReload_all_refreshesAllCircuitBreakers() throws Exception {
// 초기: API_FULL_A 등록 OPEN 상태로 만들기
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-FULL-A", "API_FULL_A", "1")));
manager.reload();
CircuitBreaker cbA = manager.getCircuitBreaker("API_FULL_A");
recordFailures(cbA, 4);
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "전체 reload 전 API_FULL_A 는 OPEN 이어야 한다");
// 전체 reload CircuitBreakerRegistry 재생성
// init() apiIdCBConfigMap 초기화하지 않으므로 API_FULL_A 맵에 남아있다.
// 하지만 레지스트리를 생성하므로 CB 상태(OPEN) 초기화(CLOSED)된다.
when(mockDao.getList()).thenReturn(Arrays.asList(
buildVo("uuid-FULL-A", "API_FULL_A", "1"),
buildVo("uuid-FULL-B", "API_FULL_B", "1")));
manager.reload();
// 레지스트리 재생성으로 API_FULL_A CB 상태가 CLOSED 초기화되어야 한다
CircuitBreaker cbAAfter = manager.getCircuitBreaker("API_FULL_A");
assertEquals(CircuitBreaker.State.CLOSED, cbAAfter.getState(),
"전체 reload 후 CircuitBreakerRegistry 재생성으로 이전 CB 상태가 CLOSED 로 초기화되어야 한다");
// apiId 정상 등록
assertEquals("API_FULL_B", manager.getCircuitBreaker("API_FULL_B").getName(),
"전체 reload 후 새 apiId 는 정상 등록되어야 한다");
}
// =========================================================================
// 5. 생명주기
// =========================================================================
@Test
@DisplayName("5-1. start() 후 isStarted() = true")
void testIsStarted_afterStart_returnsTrue() {
assertTrue(manager.isStarted(), "start() 후 isStarted() 는 true 여야 한다");
}
}
@@ -0,0 +1,158 @@
package com.eactive.eai.common.hsm;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.File;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import java.util.Enumeration;
import java.util.concurrent.TimeUnit;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* SoftHSM2 마스터키 등록 유틸리티 테스트.
*
* 용도: CryptoModuleConfig.keyDerivParams hsmKeyAlias 대응하는
* AES-256 마스터키를 SoftHSM2 토큰에 등록한다.
*
* 사전 조건:
* C:\SoftHSM2 SoftHSM2 설치
* (토큰이 없으면 자동 초기화, 있으면 기존 토큰 재사용)
*
* 실행 확인:
* softhsm2-util.exe --show-slots
* 또는 pkcs11-tool --list-objects (OpenSC 설치 )
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HsmKeyRegisterUtil {
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
/** DB CryptoModuleConfig.keyDerivParams.hsmKeyAlias 와 반드시 일치해야 한다. */
private static final String MASTER_KEY_ALIAS = "MASTER_KEY";
private static Provider provider;
private static KeyStore keyStore;
@BeforeAll
static void setUp() throws Exception {
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
ensureTokenInitialized();
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL + "\n"
+ "slotListIndex = 0\n";
provider = HsmManager.createProvider(cfgContent);
Provider existing = Security.getProvider(provider.getName());
if (existing != null) Security.removeProvider(existing.getName());
Security.addProvider(provider);
keyStore = KeyStore.getInstance("PKCS11", provider);
keyStore.load(null, PIN.toCharArray());
}
// ------------------------------------------------------------------
// 1. 현재 등록된 목록 출력
// ------------------------------------------------------------------
@Test
@Order(1)
void listKeys() throws Exception {
System.out.println("=== HSM 키 목록 ===");
Enumeration<String> aliases = keyStore.aliases();
if (!aliases.hasMoreElements()) {
System.out.println(" (등록된 키 없음)");
}
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
String type = keyStore.isKeyEntry(alias) ? "KEY" : "CERT";
System.out.println(" [" + type + "] " + alias);
}
System.out.println("==================");
}
// ------------------------------------------------------------------
// 2. 마스터키 등록 (없는 경우에만)
// ------------------------------------------------------------------
@Test
@Order(2)
void registerMasterKey() throws Exception {
if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
System.out.println("[SKIP] 이미 등록됨: " + MASTER_KEY_ALIAS);
return;
}
KeyGenerator kg = KeyGenerator.getInstance("AES", provider);
kg.init(256);
SecretKey sk = kg.generateKey();
keyStore.setKeyEntry(MASTER_KEY_ALIAS, sk, null, null);
System.out.println("[OK] AES-256 마스터키 등록 완료: alias=" + MASTER_KEY_ALIAS);
}
// ------------------------------------------------------------------
// 3. 등록 조회 확인
// ------------------------------------------------------------------
@Test
@Order(3)
void verifyMasterKey() throws Exception {
keyStore.load(null, PIN.toCharArray()); // 갱신
assert keyStore.containsAlias(MASTER_KEY_ALIAS)
: "마스터키 등록 실패: " + MASTER_KEY_ALIAS;
SecretKey sk = (SecretKey) keyStore.getKey(MASTER_KEY_ALIAS, null);
assert sk != null : "키 조회 실패";
assert "AES".equals(sk.getAlgorithm()) : "알고리즘 불일치: " + sk.getAlgorithm();
System.out.println("[OK] 마스터키 조회 성공: alias=" + MASTER_KEY_ALIAS
+ " / algorithm=" + sk.getAlgorithm());
}
// ------------------------------------------------------------------
// 헬퍼
// ------------------------------------------------------------------
private static void ensureTokenInitialized() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL,
"--pin", PIN,
"--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
System.out.println("[SoftHSM2] init-token: " + out.trim());
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -0,0 +1,304 @@
package com.eactive.eai.common.hsm;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.security.PublicKey;
import java.util.concurrent.TimeUnit;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.mockito.Mockito;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HsmManager / HsmCryptoService 통합 테스트 (JDK 8 / SunPKCS11 / SoftHSM2)
*
* 사전 조건:
* SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases
* C:\SoftHSM2 설치 (미설치 테스트 자동 건너뜀)
*
* PropManager DB 없이 Mockito Mock 으로 대체하여 테스트합니다.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HsmManagerServiceTest {
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String RSA_ALIAS = "test-rsa-key";
private static final String AES_ALIAS = "test-aes-key";
private static HsmManager hsmManager;
private static HsmCryptoService hsmCryptoService;
private static GenericApplicationContext springContext;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUp() throws Exception {
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
// 1. 토큰 초기화 (없으면 신규 생성, 있으면 기존 사용)
ensureTokenInitialized();
// 2. PKCS11 설정 문자열 구성 (slotListIndex = 0 번째 초기화 토큰)
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n"
+ "slotListIndex = 0\n";
// keytool 호출 파일 경로가 필요하므로 임시 파일에도 저장
tempCfgFile = File.createTempFile("pkcs11-svc-test-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. PropManager Mock - DB 없이 HSM 설정값 반환
PropManager mockPropManager = Mockito.mock(PropManager.class);
Mockito.when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent);
Mockito.when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN);
// 4. HsmManager 인스턴스 생성 (private 생성자 리플렉션)
Constructor<HsmManager> ctor = HsmManager.class.getDeclaredConstructor();
ctor.setAccessible(true);
hsmManager = ctor.newInstance();
// 5. Spring ApplicationContext 구성
// ApplicationContextProvider.context 세팅하여
// HsmManager.getInstance() / PropManager.getInstance() 동작하게
springContext = new GenericApplicationContext();
springContext.getBeanFactory().registerSingleton("propManager", mockPropManager);
springContext.getBeanFactory().registerSingleton("hsmManager", hsmManager);
springContext.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
springContext.refresh();
// 6. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈)
hsmManager.start();
assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패");
System.out.println("[HsmManager] 초기화 완료. provider=" + hsmManager.getPkcs11Provider().getName());
// 7. RSA 키쌍 생성 (없는 경우에만)
if (!hsmManager.getKeyStore().containsAlias(RSA_ALIAS)) {
generateRsaKeyPair();
hsmManager.getKeyStore().load(null, PIN.toCharArray()); // keytool 결과 반영
}
// 8. AES 생성 (없는 경우에만)
if (!hsmManager.getKeyStore().containsAlias(AES_ALIAS)) {
generateAesKey();
}
// 9. HsmCryptoService 생성 (HsmManager.getInstance() 내부에서 사용)
hsmCryptoService = new HsmCryptoService();
}
@AfterAll
static void tearDown() throws Exception {
if (hsmManager != null && hsmManager.isStarted()) {
hsmManager.stop();
}
if (springContext != null) {
springContext.close();
}
if (tempCfgFile != null) {
tempCfgFile.delete();
}
}
// -------------------------------------------------------------------------
// 테스트: HsmManager 상태
// -------------------------------------------------------------------------
@Test
@Order(1)
void testHsmManagerReady() {
assertTrue(hsmManager.isReady());
assertNotNull(hsmManager.getPkcs11Provider());
assertNotNull(hsmManager.getKeyStore());
System.out.println("[HsmManager] isReady=true / provider="
+ hsmManager.getPkcs11Provider().getName());
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - 조회
// -------------------------------------------------------------------------
@Test
@Order(2)
void testGetPublicKey() throws Exception {
PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS);
assertNotNull(pk);
assertEquals("RSA", pk.getAlgorithm());
System.out.println("[HsmCryptoService] getPublicKey: algorithm=" + pk.getAlgorithm()
+ " / format=" + pk.getFormat());
}
@Test
@Order(3)
void testGetSecretKey() throws Exception {
SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS);
assertNotNull(sk);
assertEquals("AES", sk.getAlgorithm());
System.out.println("[HsmCryptoService] getSecretKey: algorithm=" + sk.getAlgorithm());
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - RSA 암복호화
// -------------------------------------------------------------------------
@Test
@Order(4)
void testEncryptRsaByAlias() throws Exception {
byte[] plaintext = "RSA alias 암호화 테스트".getBytes("UTF-8");
byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext);
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HsmCryptoService] encryptRsa(alias): ciphertext.length=" + ciphertext.length);
}
@Test
@Order(5)
void testEncryptRsaByPublicKey() throws Exception {
PublicKey pk = hsmCryptoService.getPublicKey(RSA_ALIAS);
byte[] plaintext = "RSA PublicKey 직접 암호화 테스트".getBytes("UTF-8");
byte[] ciphertext = hsmCryptoService.encryptRsa(pk, plaintext);
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HsmCryptoService] encryptRsa(PublicKey): ciphertext.length=" + ciphertext.length);
}
@Test
@Order(6)
void testRsaRoundTrip() throws Exception {
byte[] plaintext = "HSM RSA 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8");
// 암호화: 공개키 JVM 소프트웨어 (encryptRsa 내부에서 Provider 미명시)
byte[] ciphertext = hsmCryptoService.encryptRsa(RSA_ALIAS, plaintext);
// 복호화: 개인키 핸들 HSM 내부 (decryptRsa 내부에서 pkcs11Provider 명시)
byte[] decrypted = hsmCryptoService.decryptRsa(RSA_ALIAS, PIN.toCharArray(), ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HsmCryptoService] RSA 암복호화 성공: " + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: HsmCryptoService - AES 암복호화
// -------------------------------------------------------------------------
@Test
@Order(7)
void testAesRoundTrip() throws Exception {
byte[] plaintext = "HSM AES 암복호화 통합 테스트 - HsmCryptoService".getBytes("UTF-8");
byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트
SecretKey sk = hsmCryptoService.getSecretKey(AES_ALIAS);
byte[] ciphertext = hsmCryptoService.encryptAes(sk, iv, plaintext);
byte[] decrypted = hsmCryptoService.decryptAes(sk, iv, ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HsmCryptoService] AES 암복호화 성공: " + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: HsmManager Lifecycle - stop / restart
// -------------------------------------------------------------------------
@Test
@Order(8)
void testHsmManagerStopAndRestart() throws Exception {
// stop
hsmManager.stop();
assertFalse(hsmManager.isStarted());
assertFalse(hsmManager.isReady());
System.out.println("[HsmManager] stop 완료");
// restart - PropManager Mock 에서 재설정 읽어 재초기화
hsmManager.start();
assertTrue(hsmManager.isStarted());
assertTrue(hsmManager.isReady());
System.out.println("[HsmManager] restart 완료. provider="
+ hsmManager.getPkcs11Provider().getName());
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void ensureTokenInitialized() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL,
"--pin", PIN,
"--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
System.out.println("[SoftHSM2] init-token: " + out.trim());
}
private static void generateRsaKeyPair() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"keytool", "-genkeypair",
"-alias", RSA_ALIAS,
"-keyalg", "RSA",
"-keysize", "2048",
"-storetype", "PKCS11",
"-providerclass", "sun.security.pkcs11.SunPKCS11",
"-providerarg", tempCfgFile.getAbsolutePath(),
"-keystore", "NONE",
"-storepass", PIN,
"-dname", "CN=HSM Test, O=eActive, C=KR",
"-noprompt");
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
if (!p.waitFor(30, TimeUnit.SECONDS)) {
p.destroy();
throw new RuntimeException("keytool -genkeypair timeout");
}
System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + out.trim());
}
private static void generateAesKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES", hsmManager.getPkcs11Provider());
kg.init(256);
SecretKey sk = kg.generateKey();
hsmManager.getKeyStore().setKeyEntry(AES_ALIAS, sk, null, null);
System.out.println("[HsmManager] AES-256 키 생성 완료. alias=" + AES_ALIAS);
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -0,0 +1,310 @@
package com.eactive.eai.common.hsm;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.security.Key;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.concurrent.TimeUnit;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* SoftHSM2 연동 통합 테스트 (JDK 8 / SunPKCS11)
*
* 사전 조건:
* 1. SoftHSM2 설치: https://github.com/disig/SoftHSM2-for-Windows/releases
* SoftHSM2-*-x64.msi 다운로드 C:\SoftHSM2 설치
* 2. 토큰 초기화 (최초 1회):
* "C:\SoftHSM2\bin\softhsm2-util.exe" --init-token --free --label "eapim-test" --pin 1234 --so-pin 1234
* 이미 초기화된 경우 @BeforeAll 에서 자동 처리
*
* SoftHSM2 미설치 모든 테스트는 자동으로 건너뜀.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class HsmSoftHsm2IntegrationTest {
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String RSA_ALIAS = "test-rsa-key";
private static final String AES_ALIAS = "test-aes-key";
private static Provider provider;
private static KeyStore keyStore;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpHsm() throws Exception {
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
// 1. 토큰 초기화 (이미 존재하면 출력만 남기고 계속 진행)
initToken();
// 2. pkcs11 설정 내용 (slotListIndex = 0 번째 초기화된 토큰 사용)
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL.replace("\\", "/") + "\n"
+ "slotListIndex = 0\n";
// keytool 파일 경로를 요구하므로 임시 파일에도 저장
tempCfgFile = File.createTempFile("pkcs11-test-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. SunPKCS11 Provider 초기화 (JDK 8 / JDK 9+ 공용)
provider = HsmManager.createProvider(cfgContent);
Provider existing = Security.getProvider(provider.getName());
if (existing != null) {
Security.removeProvider(existing.getName());
}
Security.addProvider(provider);
// 4. PKCS11 KeyStore 오픈
keyStore = KeyStore.getInstance("PKCS11", provider);
keyStore.load(null, PIN.toCharArray());
// 5. RSA 키쌍 생성 (keytool 경유 자체서명 인증서 포함)
if (!keyStore.containsAlias(RSA_ALIAS)) {
generateRsaKeyPair();
keyStore.load(null, PIN.toCharArray()); // keytool 결과 반영
}
// 6. AES 생성 (KeyGenerator 경유)
if (!keyStore.containsAlias(AES_ALIAS)) {
generateAesKey();
}
}
@AfterAll
static void tearDown() {
if (provider != null) {
Security.removeProvider(provider.getName());
}
if (tempCfgFile != null) {
tempCfgFile.delete();
}
}
// -------------------------------------------------------------------------
// 테스트: Provider / KeyStore
// -------------------------------------------------------------------------
@Test
@Order(1)
void testProviderInitialized() {
assertNotNull(provider);
assertTrue(provider.getName().startsWith("SunPKCS11"),
"예상 Provider 이름: SunPKCS11-*, 실제: " + provider.getName());
System.out.println("[HSM] Provider: " + provider.getName()
+ " / version: " + provider.getVersion());
}
@Test
@Order(2)
void testKeyStoreContainsRsaAlias() throws Exception {
assertTrue(keyStore.containsAlias(RSA_ALIAS),
"RSA 키 없음. alias=" + RSA_ALIAS);
}
@Test
@Order(3)
void testKeyStoreContainsAesAlias() throws Exception {
assertTrue(keyStore.containsAlias(AES_ALIAS),
"AES 키 없음. alias=" + AES_ALIAS);
}
// -------------------------------------------------------------------------
// 테스트: 조회 (HsmCryptoService.getPublicKey / getSecretKey 해당)
// -------------------------------------------------------------------------
@Test
@Order(4)
void testGetPublicKey() throws Exception {
Certificate cert = keyStore.getCertificate(RSA_ALIAS);
assertNotNull(cert, "인증서 없음. alias=" + RSA_ALIAS);
PublicKey pk = cert.getPublicKey();
assertNotNull(pk);
assertEquals("RSA", pk.getAlgorithm());
System.out.println("[HSM] PublicKey algorithm=" + pk.getAlgorithm()
+ " / format=" + pk.getFormat());
}
@Test
@Order(5)
void testGetSecretKey() throws Exception {
Key key = keyStore.getKey(AES_ALIAS, null);
assertNotNull(key, "AES 키 없음. alias=" + AES_ALIAS);
assertInstanceOf(SecretKey.class, key);
assertEquals("AES", key.getAlgorithm());
System.out.println("[HSM] SecretKey algorithm=" + key.getAlgorithm());
}
// -------------------------------------------------------------------------
// 테스트: RSA 암복호화 (HsmCryptoService.encryptRsa / decryptRsa 해당)
// -------------------------------------------------------------------------
@Test
@Order(6)
void testRsaEncrypt() throws Exception {
PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey();
// Provider 미명시 JVM SunRsaSign 으로 암호화 (HsmCryptoService.encryptRsa 동작)
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] ciphertext = cipher.doFinal("테스트 평문".getBytes("UTF-8"));
assertNotNull(ciphertext);
assertTrue(ciphertext.length > 0);
System.out.println("[HSM] RSA 암호화 성공. ciphertext.length=" + ciphertext.length
+ " / provider=" + cipher.getProvider().getName());
}
@Test
@Order(7)
void testRsaEncryptDecryptRoundTrip() throws Exception {
byte[] plaintext = "HSM RSA 암복호화 테스트 - eapim-online".getBytes("UTF-8");
// 암호화: 공개키 JVM 소프트웨어 (Provider 미명시)
PublicKey publicKey = keyStore.getCertificate(RSA_ALIAS).getPublicKey();
Cipher encCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
encCipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] ciphertext = encCipher.doFinal(plaintext);
// 복호화: 개인키 핸들 HSM 내부 수행 (pkcs11Provider 명시)
PrivateKey privateKey = (PrivateKey) keyStore.getKey(RSA_ALIAS, PIN.toCharArray());
Cipher decCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding", provider);
decCipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decrypted = decCipher.doFinal(ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HSM] RSA 암복호화 성공."
+ " enc.provider=" + encCipher.getProvider().getName()
+ " / dec.provider=" + decCipher.getProvider().getName()
+ " / plaintext=" + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 테스트: AES 암복호화 (HsmCryptoService.encryptAes / decryptAes 해당)
// -------------------------------------------------------------------------
@Test
@Order(8)
void testAesEncryptDecryptRoundTrip() throws Exception {
byte[] plaintext = "HSM AES 암복호화 테스트 - eapim-online".getBytes("UTF-8");
byte[] iv = "1234567890123456".getBytes("UTF-8"); // 16바이트 IV
SecretKey secretKey = (SecretKey) keyStore.getKey(AES_ALIAS, null);
// 암호화
Cipher encCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
encCipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv));
byte[] ciphertext = encCipher.doFinal(plaintext);
// 복호화
Cipher decCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
decCipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv));
byte[] decrypted = decCipher.doFinal(ciphertext);
assertArrayEquals(plaintext, decrypted);
System.out.println("[HSM] AES 암복호화 성공. plaintext=" + new String(decrypted, "UTF-8"));
}
// -------------------------------------------------------------------------
// 헬퍼
// -------------------------------------------------------------------------
private static void initToken() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL,
"--pin", PIN,
"--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String output = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
// 이미 초기화된 슬롯이 없으면 실패할 있으나 무시 (기존 토큰 재사용)
System.out.println("[SoftHSM2] init-token: " + output.trim());
}
/**
* keytool RSA 키쌍 + 자체서명 인증서를 PKCS11 토큰에 생성.
* 인증서가 있어야 PKCS11 KeyStore 에서 alias 조회 가능.
*/
private static void generateRsaKeyPair() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
"keytool", "-genkeypair",
"-alias", RSA_ALIAS,
"-keyalg", "RSA",
"-keysize", "2048",
"-storetype", "PKCS11",
"-providerclass", "sun.security.pkcs11.SunPKCS11",
"-providerarg", tempCfgFile.getAbsolutePath(),
"-keystore", "NONE",
"-storepass", PIN,
"-dname", "CN=HSM Test, O=eActive, C=KR",
"-noprompt");
pb.redirectErrorStream(true);
Process p = pb.start();
String output = readOutput(p);
boolean finished = p.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
p.destroy();
throw new RuntimeException("keytool -genkeypair timeout");
}
System.out.println("[keytool] genkeypair exit=" + p.exitValue() + ": " + output.trim());
}
/**
* KeyGenerator AES-256 키를 HSM 토큰에 직접 생성.
* SoftHSM2 AES 키는 기본적으로 extractable(추출 가능).
*/
private static void generateAesKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("AES", provider);
kg.init(256);
SecretKey sk = kg.generateKey();
keyStore.setKeyEntry(AES_ALIAS, sk, null, null);
System.out.println("[HSM] AES-256 키 생성 완료. alias=" + AES_ALIAS);
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -1,7 +1,12 @@
package com.eactive.eai.common.inflow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.domain.EntityScan;
@@ -21,21 +26,59 @@ import com.eactive.eai.data.jpa.BaseRepositoryImpl;
@EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class, basePackages = {
"com.eactive.eai.common.inflow" })
@EntityScan({ "com.eactive.eai.data.entity.onl.inflow" })
@Sql({ "/com/eactive/eai/common/inflow/init_tseaifr09.sql", "/com/eactive/eai/common/inflow/init_tseaifr11.sql" })
@Sql({
"/com/eactive/eai/common/inflow/init_tseaifr09.sql",
"/com/eactive/eai/common/inflow/init_tseaifr11.sql",
"/com/eactive/eai/common/inflow/init_inflow_control_group.sql",
"/com/eactive/eai/common/inflow/init_inflow_control_group_mapping.sql"
})
class InflowControlManagerTest {
@Autowired
InflowControlManager inflowControlManager;
@BeforeEach
void setUp() throws Exception {
if (!inflowControlManager.isStarted()) {
inflowControlManager.start();
} else {
inflowControlManager.reloadAdapter();
inflowControlManager.reloadInterface();
inflowControlManager.reloadGroup();
}
}
@Test
void testStart() throws LifecycleException {
// given
// when
inflowControlManager.start();
// then
void testStart() {
assertTrue(inflowControlManager.isStarted());
}
@Test
void testGetGroupBucket_existingGroup_returnsNotNull() {
assertNotNull(inflowControlManager.getGroupBucket("test-group-001"));
}
@Test
void testGetGroupBucket_unknownGroup_returnsNull() {
assertNull(inflowControlManager.getGroupBucket("non-existent-group"));
}
@Test
void adapter_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
assertFalse(inflowControlManager.isAdapterPass("TEST_ADAPTER"));
}
@Test
void interface_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertTrue(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
assertFalse(inflowControlManager.isInterfacePass("TEST_INTERFACE"));
}
@Test
void group_spikePasses_thenBurstBlocked() {
for (int i = 0; i < 5; i++) assertNull(inflowControlManager.isGroupPass("test-group-quota"));
assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, inflowControlManager.isGroupPass("test-group-quota"));
}
}
@@ -0,0 +1,190 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* ClientDualInflowControlManager 클라이언트 메트릭 단위 테스트.
*/
class ClientDualInflowControlManagerMetricsTest {
private ClientDualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new ClientDualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeTargetVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(100);
vo.setThreshold(10000);
vo.setThresholdTimeUnit("DAY");
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = stableBucket(capacity);
b.tryConsume(capacity);
return b;
}
private DualCustomBucket passDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name));
}
private DualCustomBucket blockedPerSecondDualBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name));
}
private DualCustomBucket blockedThresholdDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name));
}
private void injectClientMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "clientBucketMap", map);
}
// =========================================================================
// isClientPassDetail() 카운터 증가
// =========================================================================
@Test
void isClientPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isClientPassDetail("UNKNOWN");
assertNull(manager.getClientMetrics("UNKNOWN"));
}
@Test
void isClientPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").allowed.get());
}
@Test
void isClientPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedThresholdDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedThreshold.get());
}
@Test
void isClientPassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", blockedPerSecondDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertEquals(1, manager.getClientMetrics("C1").rejectedPerSecond.get());
}
// =========================================================================
// getAllClientMetrics 조회 불변성
// =========================================================================
@Test
void getAllClientMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllClientMetrics().isEmpty());
}
@Test
void getAllClientMetrics_afterCalls_containsEntries() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllClientMetrics();
assertEquals(2, all.size());
assertTrue(all.containsKey("C1"));
assertTrue(all.containsKey("C2"));
}
// =========================================================================
// resetClientMetrics / resetAllClientMetrics
// =========================================================================
@Test
void resetClientMetrics_resetsTargetClient() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.resetClientMetrics("C1");
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
}
@Test
void resetAllClientMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
map.put("C2", passDualBucket("C2"));
injectClientMap(map);
manager.isClientPassDetail("C1");
manager.isClientPassDetail("C2");
manager.resetAllClientMetrics();
assertEquals(0, manager.getClientMetrics("C1").allowed.get());
assertEquals(0, manager.getClientMetrics("C2").allowed.get());
}
// =========================================================================
// removeClient 메트릭 자동 삭제
// =========================================================================
@Test
void removeClient_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("C1", passDualBucket("C1"));
injectClientMap(map);
manager.isClientPassDetail("C1");
assertNotNull(manager.getClientMetrics("C1"));
manager.removeClient("C1");
assertNull(manager.getClientMetrics("C1"));
}
}
@@ -0,0 +1,164 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
class DualCustomBucketTest {
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
private LocalBucket freshBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = freshBucket(capacity);
b.tryConsume(capacity);
return b;
}
// -------------------------------------------------------------------------
// tryConsume 통과 케이스
// -------------------------------------------------------------------------
@Test
void bothBucketsNull_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(null, null, makeVO("A", 0, 0, null));
assertNull(bucket.tryConsume());
}
@Test
void onlyPerSecond_withinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), null, makeVO("A", 5, 0, null));
assertNull(bucket.tryConsume());
}
@Test
void onlyThreshold_withinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(null, freshBucket(100), makeVO("A", 0, 100, "MIN"));
assertNull(bucket.tryConsume());
}
@Test
void bothBuckets_bothWithinLimit_returnsPass() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), freshBucket(100), makeVO("A", 10, 100, "MIN"));
assertNull(bucket.tryConsume());
}
// -------------------------------------------------------------------------
// tryConsume 차단 케이스
// -------------------------------------------------------------------------
@Test
void onlyPerSecond_exceeded_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), null, makeVO("A", 1, 0, null));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void onlyThreshold_exceeded_returnsBlockedThreshold() {
DualCustomBucket bucket = new DualCustomBucket(null, exhaustedBucket(1), makeVO("A", 0, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
@Test
void perSecondExceeded_thresholdRemaining_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), freshBucket(100), makeVO("A", 1, 100, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void perSecondRemaining_thresholdExceeded_returnsBlockedThreshold() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), exhaustedBucket(1), makeVO("A", 10, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
@Test
void bothExceeded_returnsBlockedPerSecond() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), exhaustedBucket(1), makeVO("A", 1, 1, "MIN"));
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
// -------------------------------------------------------------------------
// tryConsume 연속 호출
// -------------------------------------------------------------------------
@Test
void consecutiveConsumes_blockedAfterCapacityExhausted() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(3), null, makeVO("A", 3, 0, null));
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void bothBuckets_thresholdExhaustedAfterMultiplePasses() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(10), freshBucket(3), makeVO("A", 10, 3, "MIN"));
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// -------------------------------------------------------------------------
// 가용 토큰 조회
// -------------------------------------------------------------------------
@Test
void nullBuckets_availableTokensReturnMinusOne() {
DualCustomBucket bucket = new DualCustomBucket(null, null, makeVO("A", 0, 0, null));
assertEquals(-1, bucket.getPerSecondAvailableTokens());
assertEquals(-1, bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensReflectInitialCapacity() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), freshBucket(100), makeVO("A", 5, 100, "MIN"));
assertEquals(5, bucket.getPerSecondAvailableTokens());
assertEquals(100, bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensDecrementAfterEachConsume() {
DualCustomBucket bucket = new DualCustomBucket(freshBucket(5), freshBucket(10), makeVO("A", 5, 10, "MIN"));
bucket.tryConsume();
assertEquals(4, bucket.getPerSecondAvailableTokens());
assertEquals(9, bucket.getThresholdAvailableTokens());
bucket.tryConsume();
assertEquals(3, bucket.getPerSecondAvailableTokens());
assertEquals(8, bucket.getThresholdAvailableTokens());
}
@Test
void perSecondBlocked_thresholdTokenNotConsumed() {
DualCustomBucket bucket = new DualCustomBucket(exhaustedBucket(1), freshBucket(10), makeVO("A", 1, 10, "MIN"));
long thresholdBefore = bucket.getThresholdAvailableTokens();
bucket.tryConsume();
assertEquals(thresholdBefore, bucket.getThresholdAvailableTokens());
}
@Test
void resultPassConstant_isNull() {
assertNull(DualCustomBucket.RESULT_PASS);
}
}
@@ -0,0 +1,344 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualCustomBucket / DualInflowControlManager 동시성 테스트.
*
* <p>모든 버킷을 Duration.ofHours(1) 생성하여 테스트 실행 리필이 없도록 한다.
* CountDownLatch로 모든 스레드를 동시 출발시켜 race window를 최대화한다.
*/
class DualInflowControlConcurrencyTest {
private static final int THREAD_COUNT = 20;
private static final int TRIES_PER_THREAD = 50;
private static final int TOTAL_TRIES = THREAD_COUNT * TRIES_PER_THREAD; // 1000
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeVO(String name, long perSec, long threshold, String unit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(perSec);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(unit);
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private boolean runConcurrently(int threads, int triesPerThread, Runnable work)
throws InterruptedException {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
ExecutorService executor = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int j = 0; j < triesPerThread; j++) {
work.run();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
ready.await();
start.countDown();
boolean completed = done.await(30, TimeUnit.SECONDS);
executor.shutdown();
return completed;
}
// =========================================================================
// DualCustomBucket 동시성 테스트
// =========================================================================
@Test
void perSecondOnly_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(capacity), null, makeVO("A", capacity, 0, null));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(capacity, passCount.get());
assertEquals(0, blockedThreshold.get());
}
@Test
void thresholdOnly_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
null, stableBucket(capacity), makeVO("A", 0, capacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_THRESHOLD.equals(result)) blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedThreshold.get());
assertEquals(capacity, passCount.get());
}
@Test
void dualBuckets_thresholdIsBottleneck_totalPassEqualsThreshold() throws InterruptedException {
long perSecCapacity = 2000;
long threshCapacity = 200;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(perSecCapacity), stableBucket(threshCapacity),
makeVO("A", perSecCapacity, threshCapacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(threshCapacity, passCount.get());
assertEquals(0, blockedPerSecond.get());
}
@Test
void dualBuckets_perSecondIsBottleneck_noThresholdTokenLeak() throws InterruptedException {
long perSecCapacity = 100;
long threshCapacity = 2000;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(perSecCapacity), stableBucket(threshCapacity),
makeVO("A", perSecCapacity, threshCapacity, "HOUR"));
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockedPerSecond = new AtomicInteger();
AtomicInteger blockedThreshold = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = bucket.tryConsume();
if (result == null) passCount.incrementAndGet();
else if (DualCustomBucket.RESULT_BLOCKED_PER_SECOND.equals(result)) blockedPerSecond.incrementAndGet();
else blockedThreshold.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockedPerSecond.get() + blockedThreshold.get());
assertEquals(perSecCapacity, passCount.get());
assertEquals(0, blockedThreshold.get());
assertEquals(perSecCapacity, threshCapacity - bucket.getThresholdAvailableTokens());
}
@Test
void availableTokensConsistentAfterConcurrentConsume() throws InterruptedException {
long capacity = 300;
DualCustomBucket bucket = new DualCustomBucket(
stableBucket(capacity), null, makeVO("A", capacity, 0, null));
AtomicInteger passCount = new AtomicInteger();
runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
if (bucket.tryConsume() == null) passCount.incrementAndGet();
});
long remaining = bucket.getPerSecondAvailableTokens();
assertEquals(capacity, passCount.get() + remaining);
}
// =========================================================================
// DualInflowControlManager 동시성 테스트
// =========================================================================
@Test
void manager_isAdapterPassDetail_concurrent_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(
stableBucket(capacity), null, makeVO("ADAPTER_A", capacity, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = manager.isAdapterPassDetail("ADAPTER_A");
if (result == null) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
@Test
void manager_isInterfacePassDetail_concurrent_totalPassEqualsCapacity() throws InterruptedException {
long capacity = 200;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", new DualCustomBucket(
null, stableBucket(capacity), makeVO("IF_001", 0, capacity, "HOUR")));
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
String result = manager.isInterfacePassDetail("IF_001");
if (result == null) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
@Test
void manager_volatileMapSwap_noConcurrentException() throws InterruptedException {
String adapterName = "ADAPTER_A";
int readerCount = THREAD_COUNT;
int swapCount = 200;
Map<String, DualCustomBucket> initialMap = new ConcurrentHashMap<>();
initialMap.put(adapterName, new DualCustomBucket(
stableBucket(10_000), null, makeVO(adapterName, 10_000, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", initialMap);
AtomicReference<Throwable> error = new AtomicReference<>();
CountDownLatch ready = new CountDownLatch(readerCount + 1);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(readerCount + 1);
ExecutorService executor = Executors.newFixedThreadPool(readerCount + 1);
for (int i = 0; i < readerCount; i++) {
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int j = 0; j < TRIES_PER_THREAD; j++) {
manager.isAdapterPassDetail(adapterName);
}
} catch (Throwable t) {
error.compareAndSet(null, t);
} finally {
done.countDown();
}
});
}
executor.submit(() -> {
ready.countDown();
try {
start.await();
for (int i = 0; i < swapCount; i++) {
Map<String, DualCustomBucket> newMap = new ConcurrentHashMap<>();
newMap.put(adapterName, new DualCustomBucket(
stableBucket(10_000), null, makeVO(adapterName, 10_000, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", newMap);
}
} catch (Throwable t) {
error.compareAndSet(null, t);
} finally {
done.countDown();
}
});
ready.await();
start.countDown();
boolean completed = done.await(30, TimeUnit.SECONDS);
executor.shutdown();
assertTrue(completed, "테스트가 타임아웃되었습니다");
assertNull(error.get(), error.get() != null ? "예외 발생: " + error.get() : null);
}
@Test
void manager_mixedPassAndDetailCalls_totalPassConsistent() throws InterruptedException {
long capacity = 300;
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(
stableBucket(capacity), null, makeVO("ADAPTER_A", capacity, 0, null)));
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
AtomicInteger passCount = new AtomicInteger();
AtomicInteger blockCount = new AtomicInteger();
AtomicInteger callIndex = new AtomicInteger();
boolean done = runConcurrently(THREAD_COUNT, TRIES_PER_THREAD, () -> {
boolean useDetail = (callIndex.getAndIncrement() % 2 == 0);
boolean passed;
if (useDetail) {
passed = (manager.isAdapterPassDetail("ADAPTER_A") == null);
} else {
passed = manager.isAdapterPass("ADAPTER_A");
}
if (passed) passCount.incrementAndGet();
else blockCount.incrementAndGet();
});
assertTrue(done, "테스트가 타임아웃되었습니다");
assertEquals(TOTAL_TRIES, passCount.get() + blockCount.get());
assertEquals(capacity, passCount.get());
}
}
@@ -0,0 +1,280 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager의 Dual 기반 오버라이드 메서드 단위 테스트.
*
* <p>검증 포인트:
* - getAdapterAllKeys / getInterfaceAllKeys Dual (adapterBucketMap/interfaceBucketMap) 사용
* - getAdapterInflowThreashold / getInterfaceInflowThreashold Dual 맵의 VO 반환
* - removeAdapter / removeInterface Dual 맵에서 항목을 제거
* - base adapterBucketList / interfaceBucketList 상태와 무관하게 동작 (DAO 이중 호출 제거 효과)
*/
class DualInflowControlManagerBucketMethodsTest {
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(100);
vo.setThreshold(10000);
vo.setThresholdTimeUnit("DAY");
vo.setActivate(true);
return vo;
}
private LocalBucket bucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(1)))
.build();
}
private DualCustomBucket dualBucket(String name) {
return new DualCustomBucket(bucket(100), bucket(1000), makeVO(name));
}
private void setAdapterMap(ConcurrentHashMap<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
}
private void setInterfaceMap(ConcurrentHashMap<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
// =========================================================================
// getAdapterAllKeys
// =========================================================================
@Test
void getAdapterAllKeys_emptyMap_returnsEmptyArray() {
setAdapterMap(new ConcurrentHashMap<>());
assertEquals(0, manager.getAdapterAllKeys().length);
}
@Test
void getAdapterAllKeys_twoEntries_returnsSortedKeys() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_B", dualBucket("ADAPTER_B"));
map.put("ADAPTER_A", dualBucket("ADAPTER_A"));
setAdapterMap(map);
String[] keys = manager.getAdapterAllKeys();
assertArrayEquals(new String[]{"ADAPTER_A", "ADAPTER_B"}, keys);
}
@Test
void getAdapterAllKeys_usesDualMap_notBaseList() {
// Dual 맵에만 항목 존재 base adapterBucketList 비어 있음
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("DUAL_ONLY", dualBucket("DUAL_ONLY"));
setAdapterMap(map);
String[] keys = manager.getAdapterAllKeys();
assertEquals(1, keys.length);
assertEquals("DUAL_ONLY", keys[0]);
}
// =========================================================================
// getInterfaceAllKeys
// =========================================================================
@Test
void getInterfaceAllKeys_emptyMap_returnsEmptyArray() {
setInterfaceMap(new ConcurrentHashMap<>());
assertEquals(0, manager.getInterfaceAllKeys().length);
}
@Test
void getInterfaceAllKeys_threeEntries_returnsSortedKeys() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_C", dualBucket("IF_C"));
map.put("IF_A", dualBucket("IF_A"));
map.put("IF_B", dualBucket("IF_B"));
setInterfaceMap(map);
String[] keys = manager.getInterfaceAllKeys();
assertArrayEquals(new String[]{"IF_A", "IF_B", "IF_C"}, keys);
}
@Test
void getInterfaceAllKeys_usesDualMap_notBaseList() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("DUAL_IF", dualBucket("DUAL_IF"));
setInterfaceMap(map);
String[] keys = manager.getInterfaceAllKeys();
assertEquals(1, keys.length);
assertEquals("DUAL_IF", keys[0]);
}
// =========================================================================
// getAdapterInflowThreashold
// =========================================================================
@Test
void getAdapterInflowThreashold_found_returnsVO() {
InflowTargetVO vo = makeVO("ADAPTER_A");
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(bucket(10), bucket(100), vo));
setAdapterMap(map);
InflowTargetVO result = manager.getAdapterInflowThreashold("ADAPTER_A");
assertSame(vo, result);
}
@Test
void getAdapterInflowThreashold_notFound_returnsNull() {
setAdapterMap(new ConcurrentHashMap<>());
assertNull(manager.getAdapterInflowThreashold("MISSING"));
}
@Test
void getAdapterInflowThreashold_usesDualMap_notBaseList() {
// base adapterBucketList 에는 항목 없고 Dual 맵에만 존재
InflowTargetVO vo = makeVO("ADAPTER_A");
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", new DualCustomBucket(null, null, vo));
setAdapterMap(map);
assertSame(vo, manager.getAdapterInflowThreashold("ADAPTER_A"));
}
// =========================================================================
// getInterfaceInflowThreashold
// =========================================================================
@Test
void getInterfaceInflowThreashold_found_returnsVO() {
InflowTargetVO vo = makeVO("IF_001");
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", new DualCustomBucket(bucket(10), bucket(100), vo));
setInterfaceMap(map);
assertSame(vo, manager.getInterfaceInflowThreashold("IF_001"));
}
@Test
void getInterfaceInflowThreashold_notFound_returnsNull() {
setInterfaceMap(new ConcurrentHashMap<>());
assertNull(manager.getInterfaceInflowThreashold("MISSING"));
}
@Test
void getInterfaceInflowThreashold_usesDualMap_notBaseList() {
InflowTargetVO vo = makeVO("IF_001");
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", new DualCustomBucket(null, null, vo));
setInterfaceMap(map);
assertSame(vo, manager.getInterfaceInflowThreashold("IF_001"));
}
// =========================================================================
// removeAdapter
// =========================================================================
@Test
void removeAdapter_existingKey_removedFromDualMap() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", dualBucket("ADAPTER_A"));
map.put("ADAPTER_B", dualBucket("ADAPTER_B"));
setAdapterMap(map);
manager.removeAdapter("ADAPTER_A");
assertNull(manager.getAdapterBucket("ADAPTER_A"));
assertNotNull(manager.getAdapterBucket("ADAPTER_B"));
}
@Test
void removeAdapter_keysReflectRemoval() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", dualBucket("ADAPTER_A"));
map.put("ADAPTER_B", dualBucket("ADAPTER_B"));
setAdapterMap(map);
manager.removeAdapter("ADAPTER_A");
String[] keys = manager.getAdapterAllKeys();
assertEquals(1, keys.length);
assertEquals("ADAPTER_B", keys[0]);
}
@Test
void removeAdapter_missingKey_noException() {
setAdapterMap(new ConcurrentHashMap<>());
assertDoesNotThrow(() -> manager.removeAdapter("NOT_EXISTS"));
}
// =========================================================================
// removeInterface
// =========================================================================
@Test
void removeInterface_existingKey_removedFromDualMap() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", dualBucket("IF_001"));
map.put("IF_002", dualBucket("IF_002"));
setInterfaceMap(map);
manager.removeInterface("IF_001");
assertNull(manager.getInterfaceBucket("IF_001"));
assertNotNull(manager.getInterfaceBucket("IF_002"));
}
@Test
void removeInterface_keysReflectRemoval() {
ConcurrentHashMap<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", dualBucket("IF_001"));
map.put("IF_002", dualBucket("IF_002"));
setInterfaceMap(map);
manager.removeInterface("IF_001");
String[] keys = manager.getInterfaceAllKeys();
assertEquals(1, keys.length);
assertEquals("IF_002", keys[0]);
}
@Test
void removeInterface_missingKey_noException() {
setInterfaceMap(new ConcurrentHashMap<>());
assertDoesNotThrow(() -> manager.removeInterface("NOT_EXISTS"));
}
}
@@ -0,0 +1,92 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import com.eactive.eai.common.inflow.InflowTargetVO;
/**
* DualInflowControlManager.makeBucket() 버킷 생성 동작 검증.
*
* <p>검증 포인트:
* - threshold/perSecond 버킷이 실제로 생성되는지
* - 할당량 이내 spike는 통과하고 초과 burst는 차단되는지
* - perSecond/threshold 어느 쪽이 binding 제약인지 구분되는지
*/
class DualInflowControlManagerMakeBucketTest {
private final DualInflowControlManager manager = new DualInflowControlManager();
private InflowTargetVO vo(long perSecond, long threshold, String timeUnit) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName("TEST");
vo.setThresholdPerSecond(perSecond);
vo.setThreshold(threshold);
vo.setThresholdTimeUnit(timeUnit);
vo.setActivate(true);
return vo;
}
// =========================================================================
// threshold 단독 (5건/)
// =========================================================================
@Test
void thresholdOnly_spikePasses_thenBurstBlocked() {
DualCustomBucket bucket = manager.makeBucket(vo(0, 5, "MIN"));
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// =========================================================================
// perSecond 단독
// =========================================================================
@Test
void perSecondOnly_spikePasses_thenBurstBlocked() {
DualCustomBucket bucket = manager.makeBucket(vo(5, 0, null));
for (int i = 0; i < 5; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
// =========================================================================
// perSecond + threshold 동시 설정 binding 제약 구분
// =========================================================================
@Test
void both_perSecondIsBinding_blockedByPerSecond() {
// perSecond=3 binding: threshold=100/MIN은 여유 있음
DualCustomBucket bucket = manager.makeBucket(vo(3, 100, "MIN"));
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, bucket.tryConsume());
}
@Test
void both_thresholdIsBinding_blockedByThreshold() {
// threshold=3/MIN binding: perSecond=100은 여유 있음
DualCustomBucket bucket = manager.makeBucket(vo(100, 3, "MIN"));
for (int i = 0; i < 3; i++) assertNull(bucket.tryConsume());
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, bucket.tryConsume());
}
// =========================================================================
// 비활성 / 조건 미충족 null 반환
// =========================================================================
@Test
void inactiveVO_returnsNull() {
InflowTargetVO vo = vo(5, 0, null);
vo.setActivate(false);
assertNull(manager.makeBucket(vo));
}
@Test
void noLimitsSet_returnsNull() {
assertNull(manager.makeBucket(vo(0, 0, null)));
}
}
@@ -0,0 +1,591 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.CustomGroupBucket;
import com.eactive.eai.common.inflow.InflowGroupVO;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 메트릭 단위 테스트.
*
* <p>어댑터/인터페이스/그룹 메트릭을 검증한다.
* 클라이언트 메트릭은 ClientDualInflowControlManagerMetricsTest 에서 검증한다.
*/
class DualInflowControlManagerMetricsTest {
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowGroupVO makeGroupVO(String groupId, String groupName) {
InflowGroupVO vo = new InflowGroupVO();
vo.setGroupId(groupId);
vo.setGroupName(groupName);
vo.setActivate(true);
return vo;
}
private LocalBucket stableBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofHours(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = stableBucket(capacity);
b.tryConsume(capacity);
return b;
}
private CustomGroupBucket passBucket(String groupId) {
return new CustomGroupBucket(stableBucket(100), null, makeGroupVO(groupId, groupId + "-name"));
}
private CustomGroupBucket blockedPerSecondBucket(String groupId) {
return new CustomGroupBucket(exhaustedBucket(1), null, makeGroupVO(groupId, groupId + "-name"));
}
private CustomGroupBucket blockedThresholdBucket(String groupId) {
return new CustomGroupBucket(stableBucket(100), exhaustedBucket(1), makeGroupVO(groupId, groupId + "-name"));
}
private InflowTargetVO makeTargetVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(100);
vo.setThreshold(10000);
vo.setThresholdTimeUnit("DAY");
vo.setActivate(true);
return vo;
}
private DualCustomBucket passDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), null, makeTargetVO(name));
}
private DualCustomBucket blockedPerSecondDualBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), null, makeTargetVO(name));
}
private DualCustomBucket blockedThresholdDualBucket(String name) {
return new DualCustomBucket(stableBucket(100), exhaustedBucket(1), makeTargetVO(name));
}
private void injectAdapterMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
}
private void injectInterfaceMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
private void injectGroupBucketList(Map<String, CustomGroupBucket> map) {
ReflectionTestUtils.setField(manager, "groupBucketList", map);
}
// =========================================================================
// TargetMetrics 내부 클래스
// =========================================================================
@Test
void targetMetrics_initialState_allCountersZero() {
DualInflowControlManager.TargetMetrics m = new DualInflowControlManager.TargetMetrics();
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
assertTrue(m.lastResetTime > 0);
}
@Test
void targetMetrics_reset_countersZeroAndResetTimeUpdated() throws InterruptedException {
DualInflowControlManager.TargetMetrics m = new DualInflowControlManager.TargetMetrics();
m.allowed.set(50);
m.rejectedPerSecond.set(10);
m.rejectedThreshold.set(5);
long before = System.currentTimeMillis();
Thread.sleep(1);
m.reset();
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
assertTrue(m.lastResetTime >= before);
}
// =========================================================================
// isGroupPass() 카운터 증가
// =========================================================================
@Test
void isGroupPass_groupNotInBucketList_incrementsAllowedAndReturnsNull() {
String result = manager.isGroupPass("UNKNOWN_G");
assertNull(result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("UNKNOWN_G");
assertNotNull(m);
assertEquals(1, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_pass_incrementsAllowed() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertNull(result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(1, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", blockedPerSecondBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertEquals(CustomGroupBucket.RESULT_BLOCKED_PER_SECOND, result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(1, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isGroupPass_blockedThreshold_incrementsRejectedThreshold() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", blockedThresholdBucket("G1"));
injectGroupBucketList(map);
String result = manager.isGroupPass("G1");
assertEquals(CustomGroupBucket.RESULT_BLOCKED_THRESHOLD, result);
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(1, m.rejectedThreshold.get());
}
@Test
void isGroupPass_multipleCalls_countersAccumulate() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
LocalBucket b = stableBucket(5);
map.put("G1", new CustomGroupBucket(b, null, makeGroupVO("G1", "G1-name")));
injectGroupBucketList(map);
for (int i = 0; i < 8; i++) {
manager.isGroupPass("G1");
}
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(5, m.allowed.get());
assertEquals(3, m.rejectedPerSecond.get());
assertEquals(8, m.allowed.get() + m.rejectedPerSecond.get() + m.rejectedThreshold.get());
}
@Test
void isGroupPass_differentGroups_independentMetrics() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
map.put("G2", blockedPerSecondBucket("G2"));
injectGroupBucketList(map);
manager.isGroupPass("G1");
manager.isGroupPass("G1");
manager.isGroupPass("G2");
assertEquals(2, manager.getGroupMetrics("G1").allowed.get());
assertEquals(0, manager.getGroupMetrics("G1").rejectedPerSecond.get());
assertEquals(0, manager.getGroupMetrics("G2").allowed.get());
assertEquals(1, manager.getGroupMetrics("G2").rejectedPerSecond.get());
}
// =========================================================================
// getGroupMetrics / getAllGroupMetrics
// =========================================================================
@Test
void getGroupMetrics_beforeAnyCall_returnsNull() {
assertNull(manager.getGroupMetrics("G1"));
}
@Test
void getGroupMetrics_afterCall_returnsNonNull() {
manager.isGroupPass("G1");
assertNotNull(manager.getGroupMetrics("G1"));
}
@Test
void getAllGroupMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllGroupMetrics().isEmpty());
}
@Test
void getAllGroupMetrics_afterMultipleGroups_containsAll() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.isGroupPass("G3");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllGroupMetrics();
assertEquals(3, all.size());
assertTrue(all.containsKey("G1"));
assertTrue(all.containsKey("G2"));
assertTrue(all.containsKey("G3"));
}
@Test
void getAllGroupMetrics_returnsUnmodifiableView() {
manager.isGroupPass("G1");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllGroupMetrics();
assertThrows(UnsupportedOperationException.class, () -> all.put("NEW", null));
}
// =========================================================================
// resetGroupMetrics / resetAllGroupMetrics
// =========================================================================
@Test
void resetGroupMetrics_resetsTargetGroup() {
Map<String, CustomGroupBucket> map = new ConcurrentHashMap<>();
map.put("G1", passBucket("G1"));
injectGroupBucketList(map);
manager.isGroupPass("G1");
manager.isGroupPass("G1");
assertEquals(2, manager.getGroupMetrics("G1").allowed.get());
manager.resetGroupMetrics("G1");
DualInflowControlManager.TargetMetrics m = manager.getGroupMetrics("G1");
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void resetGroupMetrics_updatesLastResetTime() throws InterruptedException {
manager.isGroupPass("G1");
long timeBefore = manager.getGroupMetrics("G1").lastResetTime;
Thread.sleep(1);
manager.resetGroupMetrics("G1");
assertTrue(manager.getGroupMetrics("G1").lastResetTime >= timeBefore);
}
@Test
void resetGroupMetrics_nonExistentGroup_noException() {
assertDoesNotThrow(() -> manager.resetGroupMetrics("NON_EXISTENT"));
}
@Test
void resetGroupMetrics_doesNotAffectOtherGroups() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetGroupMetrics("G1");
assertEquals(0, manager.getGroupMetrics("G1").allowed.get());
assertEquals(1, manager.getGroupMetrics("G2").allowed.get());
}
@Test
void resetAllGroupMetrics_resetsAllGroups() {
manager.isGroupPass("G1");
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetAllGroupMetrics();
assertEquals(0, manager.getGroupMetrics("G1").allowed.get());
assertEquals(0, manager.getGroupMetrics("G2").allowed.get());
}
@Test
void resetAllGroupMetrics_emptyMap_noException() {
assertDoesNotThrow(() -> manager.resetAllGroupMetrics());
}
@Test
void resetAllGroupMetrics_preservesKeys() {
manager.isGroupPass("G1");
manager.isGroupPass("G2");
manager.resetAllGroupMetrics();
assertNotNull(manager.getGroupMetrics("G1"));
assertNotNull(manager.getGroupMetrics("G2"));
}
// =========================================================================
// isAdapterPassDetail() 카운터 증가
// =========================================================================
@Test
void isAdapterPassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isAdapterPassDetail("UNKNOWN");
assertNull(manager.getAdapterMetrics("UNKNOWN"));
}
@Test
void isAdapterPassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", passDualBucket("A1"));
injectAdapterMap(map);
String result = manager.isAdapterPassDetail("A1");
assertNull(result);
DualInflowControlManager.TargetMetrics m = manager.getAdapterMetrics("A1");
assertNotNull(m);
assertEquals(1, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isAdapterPassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", blockedPerSecondDualBucket("A1"));
injectAdapterMap(map);
String result = manager.isAdapterPassDetail("A1");
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND, result);
DualInflowControlManager.TargetMetrics m = manager.getAdapterMetrics("A1");
assertEquals(0, m.allowed.get());
assertEquals(1, m.rejectedPerSecond.get());
assertEquals(0, m.rejectedThreshold.get());
}
@Test
void isAdapterPassDetail_blockedThreshold_incrementsRejectedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", blockedThresholdDualBucket("A1"));
injectAdapterMap(map);
String result = manager.isAdapterPassDetail("A1");
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD, result);
DualInflowControlManager.TargetMetrics m = manager.getAdapterMetrics("A1");
assertEquals(0, m.allowed.get());
assertEquals(0, m.rejectedPerSecond.get());
assertEquals(1, m.rejectedThreshold.get());
}
@Test
void isAdapterPassDetail_multipleCalls_countersAccumulate() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
LocalBucket b = stableBucket(3);
map.put("A1", new DualCustomBucket(b, null, makeTargetVO("A1")));
injectAdapterMap(map);
for (int i = 0; i < 5; i++) manager.isAdapterPassDetail("A1");
DualInflowControlManager.TargetMetrics m = manager.getAdapterMetrics("A1");
assertEquals(3, m.allowed.get());
assertEquals(2, m.rejectedPerSecond.get());
assertEquals(5, m.allowed.get() + m.rejectedPerSecond.get() + m.rejectedThreshold.get());
}
// =========================================================================
// isInterfacePassDetail() 카운터 증가
// =========================================================================
@Test
void isInterfacePassDetail_bucketNotRegistered_noMetricsCreated() {
manager.isInterfacePassDetail("UNKNOWN");
assertNull(manager.getInterfaceMetrics("UNKNOWN"));
}
@Test
void isInterfacePassDetail_pass_incrementsAllowed() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF1", passDualBucket("IF1"));
injectInterfaceMap(map);
manager.isInterfacePassDetail("IF1");
assertEquals(1, manager.getInterfaceMetrics("IF1").allowed.get());
}
@Test
void isInterfacePassDetail_blockedPerSecond_incrementsRejectedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF1", blockedPerSecondDualBucket("IF1"));
injectInterfaceMap(map);
manager.isInterfacePassDetail("IF1");
assertEquals(1, manager.getInterfaceMetrics("IF1").rejectedPerSecond.get());
}
@Test
void isInterfacePassDetail_differentInterfaces_independentMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF1", passDualBucket("IF1"));
map.put("IF2", blockedPerSecondDualBucket("IF2"));
injectInterfaceMap(map);
manager.isInterfacePassDetail("IF1");
manager.isInterfacePassDetail("IF1");
manager.isInterfacePassDetail("IF2");
assertEquals(2, manager.getInterfaceMetrics("IF1").allowed.get());
assertEquals(1, manager.getInterfaceMetrics("IF2").rejectedPerSecond.get());
}
// =========================================================================
// getAllXxxMetrics 조회 불변성
// =========================================================================
@Test
void getAllAdapterMetrics_afterCalls_containsEntries() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", passDualBucket("A1"));
map.put("A2", passDualBucket("A2"));
injectAdapterMap(map);
manager.isAdapterPassDetail("A1");
manager.isAdapterPassDetail("A2");
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllAdapterMetrics();
assertEquals(2, all.size());
assertTrue(all.containsKey("A1"));
assertTrue(all.containsKey("A2"));
}
@Test
void getAllAdapterMetrics_returnsUnmodifiableView() {
Map<String, DualInflowControlManager.TargetMetrics> all = manager.getAllAdapterMetrics();
assertThrows(UnsupportedOperationException.class, () -> all.put("NEW", null));
}
@Test
void getAllInterfaceMetrics_empty_returnsEmptyMap() {
assertTrue(manager.getAllInterfaceMetrics().isEmpty());
}
// =========================================================================
// resetXxxMetrics / resetAllXxxMetrics
// =========================================================================
@Test
void resetAdapterMetrics_resetsTargetAdapter() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", passDualBucket("A1"));
map.put("A2", passDualBucket("A2"));
injectAdapterMap(map);
manager.isAdapterPassDetail("A1");
manager.isAdapterPassDetail("A1");
manager.isAdapterPassDetail("A2");
manager.resetAdapterMetrics("A1");
assertEquals(0, manager.getAdapterMetrics("A1").allowed.get());
assertEquals(1, manager.getAdapterMetrics("A2").allowed.get());
}
@Test
void resetAdapterMetrics_nonExistent_noException() {
assertDoesNotThrow(() -> manager.resetAdapterMetrics("NONE"));
}
@Test
void resetAllAdapterMetrics_resetsAll() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", passDualBucket("A1"));
map.put("A2", passDualBucket("A2"));
injectAdapterMap(map);
manager.isAdapterPassDetail("A1");
manager.isAdapterPassDetail("A2");
manager.resetAllAdapterMetrics();
assertEquals(0, manager.getAdapterMetrics("A1").allowed.get());
assertEquals(0, manager.getAdapterMetrics("A2").allowed.get());
}
@Test
void resetInterfaceMetrics_resetsTargetInterface() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF1", passDualBucket("IF1"));
injectInterfaceMap(map);
manager.isInterfacePassDetail("IF1");
manager.isInterfacePassDetail("IF1");
manager.resetInterfaceMetrics("IF1");
assertEquals(0, manager.getInterfaceMetrics("IF1").allowed.get());
}
@Test
void resetAllInterfaceMetrics_emptyMap_noException() {
assertDoesNotThrow(() -> manager.resetAllInterfaceMetrics());
}
// =========================================================================
// remove 메트릭 자동 삭제
// =========================================================================
@Test
void removeAdapter_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("A1", passDualBucket("A1"));
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
manager.isAdapterPassDetail("A1");
assertNotNull(manager.getAdapterMetrics("A1"));
manager.removeAdapter("A1");
assertNull(manager.getAdapterMetrics("A1"));
}
@Test
void removeInterface_cleansUpMetrics() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF1", passDualBucket("IF1"));
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
manager.isInterfacePassDetail("IF1");
assertNotNull(manager.getInterfaceMetrics("IF1"));
manager.removeInterface("IF1");
assertNull(manager.getInterfaceMetrics("IF1"));
}
}
@@ -0,0 +1,244 @@
package com.eactive.eai.common.inflow.dual;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.eactive.eai.common.inflow.InflowTargetVO;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket4j;
import io.github.bucket4j.local.LocalBucket;
/**
* DualInflowControlManager 단위 테스트.
*
* <p>start() 호출 없이 adapterBucketMap / interfaceBucketMap을
* ReflectionTestUtils로 직접 주입하여 detail 메서드만 검증한다.
*/
class DualInflowControlManagerTest {
private DualInflowControlManager manager;
@BeforeEach
void setUp() {
manager = new DualInflowControlManager();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private InflowTargetVO makeVO(String name) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(name);
vo.setThresholdPerSecond(10);
vo.setThreshold(100);
vo.setThresholdTimeUnit("MIN");
vo.setActivate(true);
return vo;
}
private LocalBucket freshBucket(long capacity) {
return Bucket4j.builder()
.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(1)))
.build();
}
private LocalBucket exhaustedBucket(long capacity) {
LocalBucket b = freshBucket(capacity);
b.tryConsume(capacity);
return b;
}
private DualCustomBucket passBucket(String name) {
return new DualCustomBucket(freshBucket(10), freshBucket(100), makeVO(name));
}
private DualCustomBucket blockedPerSecondBucket(String name) {
return new DualCustomBucket(exhaustedBucket(1), freshBucket(100), makeVO(name));
}
private DualCustomBucket blockedThresholdBucket(String name) {
return new DualCustomBucket(freshBucket(10), exhaustedBucket(1), makeVO(name));
}
private void injectAdapterMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "adapterBucketMap", map);
}
private void injectInterfaceMap(Map<String, DualCustomBucket> map) {
ReflectionTestUtils.setField(manager, "interfaceBucketMap", map);
}
// -------------------------------------------------------------------------
// isAdapterPassDetail
// -------------------------------------------------------------------------
@Test
void isAdapterPassDetail_adapterNotInMap_returnsNull() {
assertNull(manager.isAdapterPassDetail("UNKNOWN"));
}
@Test
void isAdapterPassDetail_adapterInMap_pass_returnsNull() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(map);
assertNull(manager.isAdapterPassDetail("ADAPTER_A"));
}
@Test
void isAdapterPassDetail_adapterInMap_blockedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedPerSecondBucket("ADAPTER_A"));
injectAdapterMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
manager.isAdapterPassDetail("ADAPTER_A"));
}
@Test
void isAdapterPassDetail_adapterInMap_blockedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedThresholdBucket("ADAPTER_A"));
injectAdapterMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
manager.isAdapterPassDetail("ADAPTER_A"));
}
// -------------------------------------------------------------------------
// isInterfacePassDetail
// -------------------------------------------------------------------------
@Test
void isInterfacePassDetail_interfaceNotInMap_returnsNull() {
assertNull(manager.isInterfacePassDetail("UNKNOWN"));
}
@Test
void isInterfacePassDetail_interfaceInMap_pass_returnsNull() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", passBucket("IF_001"));
injectInterfaceMap(map);
assertNull(manager.isInterfacePassDetail("IF_001"));
}
@Test
void isInterfacePassDetail_interfaceInMap_blockedPerSecond() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedPerSecondBucket("IF_001"));
injectInterfaceMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_PER_SECOND,
manager.isInterfacePassDetail("IF_001"));
}
@Test
void isInterfacePassDetail_interfaceInMap_blockedThreshold() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedThresholdBucket("IF_001"));
injectInterfaceMap(map);
assertEquals(DualCustomBucket.RESULT_BLOCKED_THRESHOLD,
manager.isInterfacePassDetail("IF_001"));
}
// -------------------------------------------------------------------------
// isAdapterPass boolean 래퍼
// -------------------------------------------------------------------------
@Test
void isAdapterPass_notInMap_returnsTrue() {
assertTrue(manager.isAdapterPass("UNKNOWN"));
}
@Test
void isAdapterPass_pass_returnsTrue() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(map);
assertTrue(manager.isAdapterPass("ADAPTER_A"));
}
@Test
void isAdapterPass_blocked_returnsFalse() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("ADAPTER_A", blockedPerSecondBucket("ADAPTER_A"));
injectAdapterMap(map);
assertFalse(manager.isAdapterPass("ADAPTER_A"));
}
// -------------------------------------------------------------------------
// isInterfacePass boolean 래퍼
// -------------------------------------------------------------------------
@Test
void isInterfacePass_notInMap_returnsTrue() {
assertTrue(manager.isInterfacePass("UNKNOWN"));
}
@Test
void isInterfacePass_pass_returnsTrue() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", passBucket("IF_001"));
injectInterfaceMap(map);
assertTrue(manager.isInterfacePass("IF_001"));
}
@Test
void isInterfacePass_blocked_returnsFalse() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
map.put("IF_001", blockedThresholdBucket("IF_001"));
injectInterfaceMap(map);
assertFalse(manager.isInterfacePass("IF_001"));
}
// -------------------------------------------------------------------------
// 모니터링 접근자
// -------------------------------------------------------------------------
@Test
void getAdapterBucket_returnsCorrectBucket() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
DualCustomBucket expected = passBucket("ADAPTER_A");
map.put("ADAPTER_A", expected);
injectAdapterMap(map);
assertSame(expected, manager.getAdapterBucket("ADAPTER_A"));
assertNull(manager.getAdapterBucket("UNKNOWN"));
}
@Test
void getInterfaceBucket_returnsCorrectBucket() {
Map<String, DualCustomBucket> map = new ConcurrentHashMap<>();
DualCustomBucket expected = passBucket("IF_001");
map.put("IF_001", expected);
injectInterfaceMap(map);
assertSame(expected, manager.getInterfaceBucket("IF_001"));
assertNull(manager.getInterfaceBucket("UNKNOWN"));
}
@Test
void getAdapterBucketMap_containsInjectedEntries() {
Map<String, DualCustomBucket> injected = new ConcurrentHashMap<>();
injected.put("ADAPTER_A", passBucket("ADAPTER_A"));
injectAdapterMap(injected);
assertTrue(manager.getAdapterBucketMap().containsKey("ADAPTER_A"));
}
}
@@ -0,0 +1,399 @@
package com.eactive.eai.common.security;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* AESCryptoModuleExtension 단위 테스트
* Spring 컨텍스트 없이 순수 암복호화 로직만 검증한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class AESCryptoModuleExtensionTest {
private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] KEY_256 = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
private static final byte[] PLAIN = "암호화 테스트 평문 데이터입니다.".getBytes(StandardCharsets.UTF_8);
// =========================================================================
// 1. CBC 모드
// =========================================================================
@Test
@DisplayName("1-1. AES/CBC/PKCS5Padding 128bit — 암복호화 라운드트립")
void testCbc128_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertNotNull(encrypted);
assertFalse(Arrays.equals(PLAIN, encrypted), "암호문은 평문과 달라야 한다");
assertArrayEquals(PLAIN, decrypted, "복호화 결과가 원문과 일치해야 한다");
}
@Test
@DisplayName("1-2. AES/CBC/PKCS5Padding 256bit — 암복호화 라운드트립")
void testCbc256_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_256, KEY_256);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("1-3. AES/CBC — 암호화 결과는 블록 크기(16바이트)의 배수")
void testCbc_ciphertextIsMultipleOfBlockSize() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
assertEquals(0, encrypted.length % 16, "CBC 암호문 길이는 16 바이트 배수여야 한다");
}
@Test
@DisplayName("1-4. AES/CBC — 잘못된 키로 복호화 시 예외 발생")
void testCbc_wrongKeyThrowsException() throws Exception {
AESCryptoModuleExtension encExt = new AESCryptoModuleExtension();
encExt.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = encExt.encrypt(PLAIN, 0, PLAIN.length);
byte[] wrongKey = "wrongkey12345678".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension decExt = new AESCryptoModuleExtension();
decExt.init("AES", "CBC", "PKCS5Padding", IV_16, wrongKey, wrongKey);
assertThrows(Exception.class, () -> decExt.decrypt(encrypted, 0, encrypted.length),
"잘못된 키로 복호화 시 예외가 발생해야 한다");
}
// =========================================================================
// 2. GCM 모드
// =========================================================================
@Test
@DisplayName("2-1. AES/GCM/NoPadding 128bit — 암복호화 라운드트립")
void testGcm128_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertNotNull(encrypted);
assertArrayEquals(PLAIN, decrypted, "GCM 복호화 결과가 원문과 일치해야 한다");
}
@Test
@DisplayName("2-2. AES/GCM — 암호화마다 랜덤 Nonce → 동일 평문도 다른 암호문 생성")
void testGcm_randomNonce_differentCiphertextEachCall() throws Exception {
AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension();
ext1.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension();
ext2.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length);
byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length);
assertFalse(Arrays.equals(enc1, enc2), "GCM은 랜덤 Nonce로 매번 다른 암호문을 생성해야 한다");
}
@Test
@DisplayName("2-3. AES/GCM — 암호문 앞 12바이트가 Nonce")
void testGcm_ciphertextStartsWithNonce() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
assertTrue(encrypted.length > 12, "GCM 암호문은 Nonce(12바이트) + 암호문 + Tag(16바이트) 구조여야 한다");
}
@Test
@DisplayName("2-4. AES/GCM — 256bit 키 라운드트립")
void testGcm256_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "GCM", "NoPadding", IV_16, KEY_256, KEY_256);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted);
}
// =========================================================================
// 3. init 편의 메서드 (IV 없는 버전)
// =========================================================================
@Test
@DisplayName("3-1. init(alg, mode, pad, encKey, decKey) — IV null로 CBC 초기화 시 예외")
void testInitWithoutIv_cbcThrowsException() {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
assertThrows(Exception.class,
() -> ext.init("AES", "CBC", "PKCS5Padding", KEY_128, KEY_128),
"IV 없이 CBC 초기화 시 예외가 발생해야 한다");
}
// =========================================================================
// 4. 바이트 배열 오프셋/길이 encrypt/decrypt
// =========================================================================
@Test
@DisplayName("4-1. encrypt(src, sindex, slength, dst, dindex) — 버퍼 직접 지정 라운드트립")
void testEncryptDecryptWithBuffer() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] dst = new byte[256];
int encLen = ext.encrypt(PLAIN, 0, PLAIN.length, dst, 0);
byte[] decDst = new byte[256];
int decLen = ext.decrypt(dst, 0, encLen, decDst, 0);
assertArrayEquals(PLAIN, Arrays.copyOf(decDst, decLen));
}
// =========================================================================
// 5. 프로바이더 파라미터
// =========================================================================
@Test
@DisplayName("5-1. provider=BC 명시 — CBC 암복호화 라운드트립")
void testProvider_bc_cbcRoundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128, "BC");
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted, "BC 프로바이더 명시 시 CBC 암복호화가 성공해야 한다");
}
@Test
@DisplayName("5-2. provider=BC 명시 — GCM 암복호화 라운드트립")
void testProvider_bc_gcmRoundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, "BC");
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted, "BC 프로바이더 명시 시 GCM 암복호화가 성공해야 한다");
}
@Test
@DisplayName("5-3. provider null — GCM 기본 동작(BC) 라운드트립")
void testProvider_null_gcmDefaultBcRoundTrip() throws Exception {
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, (String) null);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128, (String) null);
byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted, "provider=null이면 GCM은 BC 기본값으로 동작해야 한다");
}
@Test
@DisplayName("5-4. provider 파라미터 없는 CBC init — provider 없는 버전과 동일 결과")
void testProvider_cbcWithAndWithoutProvider_sameResult() throws Exception {
AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension();
ext1.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension();
ext2.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128, "BC");
byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length);
byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length);
assertArrayEquals(enc1, enc2, "동일 키/IV에서 provider 유무와 무관하게 CBC 암호문이 같아야 한다");
}
// =========================================================================
// 6. AAD (Additional Authenticated Data)
// =========================================================================
@Test
@DisplayName("6-1. GCM — 명시적 AAD로 암복호화 라운드트립")
void testGcm_explicitAad_roundTrip() throws Exception {
byte[] aad = "header-context-data".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad);
byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length, aad);
assertArrayEquals(PLAIN, decrypted, "명시적 AAD로 암복호화 라운드트립이 성공해야 한다");
}
@Test
@DisplayName("6-2. GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작")
void testGcm_nullAad_defaultIvAsAad() throws Exception {
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
// aad=null IV_16을 AAD로 사용 (기본 동작)
byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, null);
byte[] decrypted = dec.decrypt(encrypted, 0, encrypted.length, null);
assertArrayEquals(PLAIN, decrypted, "AAD=null이면 IV를 AAD로 사용하는 기본 동작이어야 한다");
}
@Test
@DisplayName("6-3. GCM — encrypt(aad=null)과 encrypt() 결과가 복호화 상호 호환")
void testGcm_nullAadCompatibleWithNoAadMethod() throws Exception {
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encryptedByNoArg = enc.encrypt(PLAIN, 0, PLAIN.length);
byte[] decryptedByNullAad = dec.decrypt(encryptedByNoArg, 0, encryptedByNoArg.length, null);
assertArrayEquals(PLAIN, decryptedByNullAad, "encrypt()와 decrypt(null)은 상호 호환되어야 한다");
}
@Test
@DisplayName("6-4. GCM — 다른 AAD로 복호화 시 인증 실패 예외")
void testGcm_wrongAad_throwsAuthException() throws Exception {
byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8);
byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
assertThrows(Exception.class,
() -> dec.decrypt(encrypted, 0, encrypted.length, wrongAad),
"잘못된 AAD로 GCM 복호화 시 인증 실패 예외가 발생해야 한다");
}
@Test
@DisplayName("6-5. GCM — AAD 있는 암호문을 AAD 없이(null IV) 복호화 시 인증 실패")
void testGcm_encryptedWithAad_decryptWithoutAad_fails() throws Exception {
byte[] aad = "must-have-aad".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", null, KEY_128, KEY_128);
byte[] encrypted = enc.encrypt(PLAIN, 0, PLAIN.length, aad);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", null, KEY_128, KEY_128);
assertThrows(Exception.class,
() -> dec.decrypt(encrypted, 0, encrypted.length, null),
"AAD로 암호화된 데이터를 AAD 없이 복호화하면 예외가 발생해야 한다");
}
@Test
@DisplayName("6-6. CBC — AAD 파라미터는 무시되고 정상 동작")
void testCbc_aadIgnored_normalOperation() throws Exception {
byte[] aad = "ignored-in-cbc".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length, aad);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length, aad);
assertArrayEquals(PLAIN, decrypted, "CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다");
}
// =========================================================================
// 7. 오프셋/길이 없는 편의 메서드
// =========================================================================
@Test
@DisplayName("7-1. CBC — encrypt(byte[]) / decrypt(byte[]) 라운드트립")
void testCbc_noOffsetEncryptDecrypt_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN);
byte[] decrypted = ext.decrypt(encrypted);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("7-2. GCM — encrypt(byte[]) / decrypt(byte[]) 라운드트립")
void testGcm_noOffsetEncryptDecrypt_roundTrip() throws Exception {
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN);
byte[] decrypted = ext.decrypt(encrypted);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("7-3. GCM — encrypt(byte[], aad) / decrypt(byte[], aad) 라운드트립")
void testGcm_noOffsetWithAad_roundTrip() throws Exception {
byte[] aad = "request-header-ctx".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension enc = new AESCryptoModuleExtension();
enc.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension dec = new AESCryptoModuleExtension();
dec.init("AES", "GCM", "NoPadding", IV_16, KEY_128, KEY_128);
byte[] encrypted = enc.encrypt(PLAIN, aad);
byte[] decrypted = dec.decrypt(encrypted, aad);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("7-4. CBC — encrypt(byte[], aad) 는 AAD 무시 후 정상 라운드트립")
void testCbc_noOffsetWithAad_aadIgnored() throws Exception {
byte[] aad = "ignored".getBytes(StandardCharsets.UTF_8);
AESCryptoModuleExtension ext = new AESCryptoModuleExtension();
ext.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, aad);
byte[] decrypted = ext.decrypt(encrypted, aad);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("7-5. encrypt(byte[])와 encrypt(src, 0, src.length) 결과 동일 — CBC")
void testCbc_noOffsetEquivalentToFullOffset() throws Exception {
AESCryptoModuleExtension ext1 = new AESCryptoModuleExtension();
ext1.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
AESCryptoModuleExtension ext2 = new AESCryptoModuleExtension();
ext2.init("AES", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] enc1 = ext1.encrypt(PLAIN);
byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length);
assertArrayEquals(enc1, enc2, "편의 메서드와 오프셋 메서드의 결과가 동일해야 한다");
}
}
@@ -0,0 +1,154 @@
package com.eactive.eai.common.security;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* ARIACryptoModuleExtension 단위 테스트
* BouncyCastle ARIA 암복호화 로직을 검증한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class ARIACryptoModuleExtensionTest {
private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] KEY_256 = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
private static final byte[] PLAIN = "ARIA 암호화 테스트 평문입니다.".getBytes(StandardCharsets.UTF_8);
// =========================================================================
// 1. CBC 모드
// =========================================================================
@Test
@DisplayName("1-1. ARIA/CBC/PKCS5Padding 128bit — 암복호화 라운드트립")
void testCbc128_roundTrip() throws Exception {
ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension();
ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertNotNull(encrypted);
assertFalse(Arrays.equals(PLAIN, encrypted), "암호문은 평문과 달라야 한다");
assertArrayEquals(PLAIN, decrypted, "복호화 결과가 원문과 일치해야 한다");
}
@Test
@DisplayName("1-2. ARIA/CBC/PKCS5Padding 256bit — 암복호화 라운드트립")
void testCbc256_roundTrip() throws Exception {
ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension();
ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_256, KEY_256);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
byte[] decrypted = ext.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(PLAIN, decrypted);
}
@Test
@DisplayName("1-3. ARIA/CBC — 암호화 결과는 블록 크기(16바이트)의 배수")
void testCbc_ciphertextIsMultipleOfBlockSize() throws Exception {
ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension();
ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = ext.encrypt(PLAIN, 0, PLAIN.length);
assertEquals(0, encrypted.length % 16, "ARIA CBC 암호문 길이는 16 바이트 배수여야 한다");
}
@Test
@DisplayName("1-4. ARIA/CBC — 다른 IV로 암호화 시 다른 암호문 생성")
void testCbc_differentIv_differentCiphertext() throws Exception {
byte[] iv2 = "9876543210fedcba".getBytes(StandardCharsets.UTF_8);
ARIACryptoModuleExtension ext1 = new ARIACryptoModuleExtension();
ext1.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
ARIACryptoModuleExtension ext2 = new ARIACryptoModuleExtension();
ext2.init("ARIA", "CBC", "PKCS5Padding", iv2, KEY_128, KEY_128);
byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length);
byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length);
assertFalse(Arrays.equals(enc1, enc2), "IV가 다르면 암호문도 달라야 한다");
}
@Test
@DisplayName("1-5. ARIA/CBC — 잘못된 키로 복호화 시 예외 발생")
void testCbc_wrongKeyThrowsException() throws Exception {
ARIACryptoModuleExtension encExt = new ARIACryptoModuleExtension();
encExt.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] encrypted = encExt.encrypt(PLAIN, 0, PLAIN.length);
byte[] wrongKey = "wrongkey12345678".getBytes(StandardCharsets.UTF_8);
ARIACryptoModuleExtension decExt = new ARIACryptoModuleExtension();
decExt.init("ARIA", "CBC", "PKCS5Padding", IV_16, wrongKey, wrongKey);
assertThrows(Exception.class, () -> decExt.decrypt(encrypted, 0, encrypted.length),
"잘못된 키로 복호화 시 예외가 발생해야 한다");
}
// =========================================================================
// 2. /복호화 분리
// =========================================================================
@Test
@DisplayName("2-1. init(alg, mode, pad, encKey, decKey) — IV null 편의 메서드")
void testInitWithoutIv() throws Exception {
ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension();
// IV 없이 초기화 (null IvParameterSpec도 null CBC에서 예외)
assertThrows(Exception.class,
() -> ext.init("ARIA", "CBC", "PKCS5Padding", KEY_128, KEY_128),
"IV 없이 CBC 초기화 시 예외가 발생해야 한다");
}
// =========================================================================
// 3. 바이트 배열 오프셋/길이 encrypt/decrypt
// =========================================================================
@Test
@DisplayName("3-1. encrypt(src, sindex, slength, dst, dindex) — 버퍼 직접 지정 라운드트립")
void testEncryptDecryptWithBuffer() throws Exception {
ARIACryptoModuleExtension ext = new ARIACryptoModuleExtension();
ext.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] dst = new byte[256];
int encLen = ext.encrypt(PLAIN, 0, PLAIN.length, dst, 0);
byte[] decDst = new byte[256];
int decLen = ext.decrypt(dst, 0, encLen, decDst, 0);
assertArrayEquals(PLAIN, Arrays.copyOf(decDst, decLen));
}
// =========================================================================
// 4. BouncyCastle Provider 자동 등록
// =========================================================================
@Test
@DisplayName("4-1. 동일 평문을 여러 번 암호화 — 각각 새 인스턴스로 독립 처리")
void testMultipleInstances_independentEncryption() throws Exception {
ARIACryptoModuleExtension ext1 = new ARIACryptoModuleExtension();
ext1.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
ARIACryptoModuleExtension ext2 = new ARIACryptoModuleExtension();
ext2.init("ARIA", "CBC", "PKCS5Padding", IV_16, KEY_128, KEY_128);
byte[] enc1 = ext1.encrypt(PLAIN, 0, PLAIN.length);
byte[] enc2 = ext2.encrypt(PLAIN, 0, PLAIN.length);
// 같은 /IV 같은 암호문
assertArrayEquals(enc1, enc2, "동일 키/IV라면 암호문이 동일해야 한다");
// 각자 복호화
assertArrayEquals(PLAIN, ext1.decrypt(enc1, 0, enc1.length));
assertArrayEquals(PLAIN, ext2.decrypt(enc2, 0, enc2.length));
}
}
@@ -0,0 +1,391 @@
package com.eactive.eai.common.security;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
/**
* CryptoModuleManager 단위 테스트
* DB / Spring 컨텍스트 없이 Mock으로 동작한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class CryptoModuleManagerTest {
private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
private static final String ENC_KEY_HEX = toHex(KEY_128);
private static final String IV_HEX = toHex(IV_16);
private static GenericApplicationContext ctx;
private static CryptoModuleConfigLoader mockLoader;
private static HsmCryptoService mockHsmCryptoService;
private static CryptoModuleManager manager;
@BeforeAll
static void setUpClass() throws Exception {
mockLoader = mock(CryptoModuleConfigLoader.class);
mockHsmCryptoService = mock(HsmCryptoService.class);
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
when(mockLoader.findAll()).thenReturn(Collections.emptyList());
Constructor<CryptoModuleManager> ctor = CryptoModuleManager.class.getDeclaredConstructor();
ctor.setAccessible(true);
manager = ctor.newInstance();
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
manager.start();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void resetMocks() {
reset(mockLoader);
when(mockLoader.findAll()).thenReturn(Collections.emptyList());
}
// =========================================================================
// 1. start / stop Lifecycle
// =========================================================================
@Test
@DisplayName("1-1. start() 후 isStarted() = true")
void testIsStarted() {
assertTrue(manager.isStarted());
}
// =========================================================================
// 2. STATIC createExtension
// =========================================================================
@Test
@DisplayName("2-1. STATIC AES/CBC — createExtension 반환 및 암복호화 동작")
void testCreateExtension_staticAesCbc_roundTrip() throws Exception {
CryptoModuleConfig config = buildStaticConfig("AES_CBC", "AES", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
byte[] plain = "테스트 평문".getBytes(StandardCharsets.UTF_8);
CryptoModuleExtension ext = manager.createExtension("AES_CBC");
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
CryptoModuleExtension extDec = manager.createExtension("AES_CBC");
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(plain, decrypted, "STATIC AES/CBC 암복호화 라운드트립 성공해야 한다");
}
@Test
@DisplayName("2-2. STATIC ARIA/CBC — createExtension 반환 및 암복호화 동작")
void testCreateExtension_staticAriaCbc_roundTrip() throws Exception {
CryptoModuleConfig config = buildStaticConfig("ARIA_CBC", "ARIA", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
byte[] plain = "ARIA 테스트".getBytes(StandardCharsets.UTF_8);
CryptoModuleExtension ext = manager.createExtension("ARIA_CBC");
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
CryptoModuleExtension extDec = manager.createExtension("ARIA_CBC");
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("2-3. 미등록 cryptoName — IllegalArgumentException")
void testCreateExtension_unknownName_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> manager.createExtension("UNKNOWN_MODULE"),
"미등록 cryptoName은 IllegalArgumentException이어야 한다");
}
@Test
@DisplayName("2-4. use_yn=N 설정 — configMap 미등록")
void testLoadAll_disabledConfig_notRegistered() throws Exception {
CryptoModuleConfig config = buildStaticConfig("DISABLED_MOD", "AES", "CBC", "PKCS5Padding");
config.setUseYn("N");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
assertThrows(IllegalArgumentException.class,
() -> manager.createExtension("DISABLED_MOD"),
"use_yn=N 설정은 로드되지 않아야 한다");
}
// =========================================================================
// 3. DYNAMIC createExtension with runtimeContext
// =========================================================================
@Test
@DisplayName("3-1. DYNAMIC AES/CBC — runtimeContext 전달 시 암복호화 동작")
void testCreateExtension_dynamicAesCbc_roundTrip() throws Exception {
CryptoModuleConfig config = buildDynamicConfig("DYN_AES_CBC", "AES", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
Map<String, String> ctx = new HashMap<>();
ctx.put("X-Api-Enc-Key", "clientKeyValue01");
byte[] plain = "DYNAMIC 키 테스트".getBytes(StandardCharsets.UTF_8);
CryptoModuleExtension ext = manager.createExtension("DYN_AES_CBC", ctx);
byte[] encrypted = ext.encrypt(plain, 0, plain.length);
CryptoModuleExtension extDec = manager.createExtension("DYN_AES_CBC", ctx);
byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);
assertArrayEquals(plain, decrypted, "DYNAMIC AES/CBC 암복호화 라운드트립 성공해야 한다");
}
@Test
@DisplayName("3-2. DYNAMIC — 동일 runtimeContext로 2회 호출 시 HSM은 1회만 호출 (캐시 적용)")
void testCreateExtension_dynamicCached_hsmCalledOnce() throws Exception {
CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE", "AES", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "cachedKeyValue01");
manager.createExtension("DYN_CACHE", runtimeCtx);
manager.createExtension("DYN_CACHE", runtimeCtx);
verify(mockHsmCryptoService, times(1)).getSecretKey(anyString());
}
@Test
@DisplayName("3-3. DYNAMIC — 잘못된 전략 FQCN — IllegalArgumentException")
void testCreateExtension_invalidStrategyFqcn_throwsException() throws Exception {
CryptoModuleConfig config = buildDynamicConfig("DYN_BAD_FQCN", "AES", "CBC", "PKCS5Padding");
config.setKeyDerivStrategy("com.example.NonExistentStrategy");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");
assertThrows(IllegalArgumentException.class,
() -> manager.createExtension("DYN_BAD_FQCN", runtimeCtx),
"존재하지 않는 FQCN이면 IllegalArgumentException이 발생해야 한다");
}
@Test
@DisplayName("3-4. DYNAMIC — 동일 FQCN 반복 호출 시 strategyCache에서 재사용")
void testCreateExtension_sameFqcn_strategyCacheReused() throws Exception {
CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE_STRAT", "AES", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");
manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);
manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);
Field stratCacheField = CryptoModuleManager.class.getDeclaredField("strategyCache");
stratCacheField.setAccessible(true);
Map<?, ?> stratCache = (Map<?, ?>) stratCacheField.get(manager);
assertEquals(1, stratCache.size(), "동일 FQCN은 strategyCache에 하나만 존재해야 한다");
}
@Test
@DisplayName("3-5. DYNAMIC — 다른 runtimeContext 값은 다른 키 도출 (동적 키 캐시 미사용)")
void testCreateExtension_dynamicDifferentContext_differentKey() throws Exception {
CryptoModuleConfig config = buildDynamicConfig("DYN_DIFF", "AES", "CBC", "PKCS5Padding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
Map<String, String> ctx1 = new HashMap<>();
ctx1.put("X-Api-Enc-Key", "keyValueAAA00001");
Map<String, String> ctx2 = new HashMap<>();
ctx2.put("X-Api-Enc-Key", "keyValueBBB00001");
manager.createExtension("DYN_DIFF", ctx1);
manager.createExtension("DYN_DIFF", ctx2);
// 컨텍스트 값이 달라 동적 캐시 미사용 HSM 2회 호출
verify(mockHsmCryptoService, times(2)).getSecretKey(anyString());
}
// =========================================================================
// 4. reload
// =========================================================================
@Test
@DisplayName("4-1. reload(cryptoId) — 변경된 설정 반영")
void testReload_specificId_configUpdated() throws Exception {
CryptoModuleConfig config = buildStaticConfig("RELOAD_MOD", "AES", "CBC", "PKCS5Padding");
config.setCryptoId("reload-id-001");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
CryptoModuleConfig updated = buildStaticConfig("RELOAD_MOD", "ARIA", "CBC", "PKCS5Padding");
updated.setCryptoId("reload-id-001");
when(mockLoader.findById("reload-id-001")).thenReturn(Optional.of(updated));
manager.reload("reload-id-001");
// 변경된 설정(ARIA)으로 암복호화 가능한지 확인
byte[] plain = "리로드 테스트".getBytes(StandardCharsets.UTF_8);
CryptoModuleExtension ext = manager.createExtension("RELOAD_MOD");
assertNotNull(ext);
}
@Test
@DisplayName("4-2. reload(cryptoId) use_yn=N — configMap에서 제거")
void testReload_specificId_disabled_removedFromMap() throws Exception {
CryptoModuleConfig config = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
config.setCryptoId("rm-id-001");
when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
reload();
CryptoModuleConfig disabled = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
disabled.setCryptoId("rm-id-001");
disabled.setUseYn("N");
when(mockLoader.findById("rm-id-001")).thenReturn(Optional.of(disabled));
manager.reload("rm-id-001");
assertThrows(IllegalArgumentException.class,
() -> manager.createExtension("RM_MOD"),
"비활성화된 모듈은 configMap에서 제거되어야 한다");
}
// =========================================================================
// 헬퍼
// =========================================================================
private void reload() throws Exception {
clearField("configMap");
clearField("dynamicKeyCache");
clearField("strategyCache");
Method loadAll = CryptoModuleManager.class.getDeclaredMethod("loadAll");
loadAll.setAccessible(true);
loadAll.invoke(manager);
reset(mockHsmCryptoService);
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
}
private void clearField(String fieldName) throws Exception {
Field field = CryptoModuleManager.class.getDeclaredField(fieldName);
field.setAccessible(true);
((Map<?, ?>) field.get(manager)).clear();
}
private CryptoModuleConfig buildStaticConfig(String name, String alg, String mode, String padding) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(java.util.UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setIvHex(IV_HEX);
c.setKeySourceType("STATIC");
c.setEncKeyHex(ENC_KEY_HEX);
c.setDecKeyHex(ENC_KEY_HEX);
c.setCacheYn("Y");
c.setCacheTtlSec(300);
c.setUseYn("Y");
return c;
}
private CryptoModuleConfig buildDynamicConfig(String name, String alg, String mode, String padding) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(java.util.UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setIvHex(IV_HEX);
c.setKeySourceType("DYNAMIC");
c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy");
c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}");
c.setCacheYn("Y");
c.setCacheTtlSec(300);
c.setUseYn("Y");
return c;
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
/** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */
private static CryptoModuleConfigMapper testMapper() {
return new CryptoModuleConfigMapper() {
@Override
public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
vo.setCryptoId(e.getCryptoId());
vo.setCryptoName(e.getCryptoName());
vo.setCryptoDesc(e.getCryptoDesc());
vo.setAlgType(e.getAlgType());
vo.setCipherMode(e.getCipherMode());
vo.setPadding(e.getPadding());
vo.setIvHex(e.getIvHex());
vo.setKeySourceType(e.getKeySourceType());
vo.setEncKeyHex(e.getEncKeyHex());
vo.setDecKeyHex(e.getDecKeyHex());
vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
vo.setKeyDerivParams(e.getKeyDerivParams());
vo.setCacheYn(e.getCacheYn());
vo.setCacheTtlSec(e.getCacheTtlSec());
vo.setUseYn(e.getUseYn());
return vo;
}
@Override
public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
return null;
}
};
}
}
@@ -0,0 +1,445 @@
package com.eactive.eai.common.security;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
/**
* CryptoModuleService 단위 테스트
* CryptoModuleManager를 통한 암복호화 진입점 전체 흐름을 검증한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class CryptoModuleServiceTest {
private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
private static final String ENC_KEY_HEX = toHex(KEY_128);
private static final String IV_HEX = toHex(IV_16);
private static GenericApplicationContext ctx;
private static CryptoModuleManager manager;
private static CryptoModuleService service;
private static CryptoModuleConfigLoader mockLoader;
private static HsmCryptoService mockHsm;
@BeforeAll
static void setUpClass() throws Exception {
mockLoader = mock(CryptoModuleConfigLoader.class);
mockHsm = mock(HsmCryptoService.class);
SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
System.out.println("masterKey:"+new String(KEY_128));
when(mockHsm.getSecretKey(anyString())).thenReturn(masterKey);
CryptoModuleConfig aes = buildStatic("AES_SVC", "AES", "CBC", "PKCS5Padding");
CryptoModuleConfig aria = buildStatic("ARIA_SVC", "ARIA", "CBC", "PKCS5Padding");
CryptoModuleConfig ecb = buildStaticNoIv("AES_ECB_SVC", "AES", "ECB", "NoPadding");
CryptoModuleConfig dyn = buildDynamic("DYN_SVC", "AES", "CBC", "PKCS5Padding");
CryptoModuleConfig gcm = buildStatic("AES_GCM_SVC", "AES", "GCM", "NoPadding");
CryptoModuleConfig gcmDyn = buildDynamic("DYN_GCM_SVC", "AES", "GCM", "NoPadding");
when(mockLoader.findAll()).thenReturn(Arrays.asList(aes, aria, ecb, dyn, gcm, gcmDyn));
Constructor<CryptoModuleManager> ctor = CryptoModuleManager.class.getDeclaredConstructor();
ctor.setAccessible(true);
manager = ctor.newInstance();
service = new CryptoModuleService();
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsm);
ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
ctx.getBeanFactory().registerSingleton("cryptoModuleService", service);
ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
manager.start();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
// =========================================================================
// 1. STATIC encrypt / decrypt
// =========================================================================
@Test
@DisplayName("1-1. STATIC AES/CBC — encrypt → decrypt 라운드트립")
void testStaticAes_encryptDecrypt() throws Exception {
byte[] plain = "CryptoModuleService AES 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_SVC", plain);
byte[] decrypted = service.decrypt("AES_SVC", encrypted);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("1-2. STATIC ARIA/CBC — encrypt → decrypt 라운드트립")
void testStaticAria_encryptDecrypt() throws Exception {
byte[] plain = "CryptoModuleService ARIA 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("ARIA_SVC", plain);
byte[] decrypted = service.decrypt("ARIA_SVC", encrypted);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("1-3. 암호문은 평문과 다른 바이트 배열")
void testEncrypt_ciphertextDiffersFromPlaintext() throws Exception {
byte[] plain = "평문입니다.".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_SVC", plain);
assertFalse(java.util.Arrays.equals(plain, encrypted));
}
@Test
@DisplayName("1-4. 미등록 모듈명 — IllegalArgumentException")
void testEncrypt_unknownModule_throwsException() {
byte[] plain = "test".getBytes(StandardCharsets.UTF_8);
assertThrows(IllegalArgumentException.class,
() -> service.encrypt("UNKNOWN_SVC", plain));
}
// =========================================================================
// 2. DYNAMIC encrypt / decrypt
// =========================================================================
@Test
@DisplayName("2-1. DYNAMIC AES/CBC — runtimeContext 전달 encrypt → decrypt 라운드트립")
void testDynamicAes_encryptDecrypt() throws Exception {
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
byte[] plain = "DYNAMIC 키 서비스 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, plain);
byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, encrypted);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("2-2. DYNAMIC — 다른 runtimeContext 값으로 복호화 시 실패")
void testDynamic_differentContext_decryptFails() throws Exception {
Map<String, String> encCtx = new HashMap<>();
encCtx.put("X-Api-Enc-Key", "encryptKeyValue0");
Map<String, String> decCtx = new HashMap<>();
decCtx.put("X-Api-Enc-Key", "differentKeyVal0");
byte[] plain = "키불일치 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_SVC", encCtx, plain);
assertThrows(Exception.class,
() -> service.decrypt("DYN_SVC", decCtx, encrypted),
"다른 컨텍스트 키로 복호화 시 예외가 발생해야 한다");
}
// =========================================================================
// 3. AES/ECB/NoPadding
// =========================================================================
@Test
@DisplayName("3-1. AES/ECB/NoPadding — 16바이트 평문 encrypt → decrypt 라운드트립")
void testEcb_encryptDecrypt_exactBlock() throws Exception {
byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8); // 정확히 16바이트
byte[] encrypted = service.encrypt("AES_ECB_SVC", plain);
byte[] decrypted = service.decrypt("AES_ECB_SVC", encrypted);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("3-2. AES/ECB/NoPadding — 동일 평문은 항상 동일 암호문 (ECB 특성)")
void testEcb_deterministicOutput() throws Exception {
byte[] plain = "ExactSixteenByte".getBytes(StandardCharsets.UTF_8);
byte[] enc1 = service.encrypt("AES_ECB_SVC", plain);
byte[] enc2 = service.encrypt("AES_ECB_SVC", plain);
assertArrayEquals(enc1, enc2, "ECB는 IV 없으므로 동일 평문 → 동일 암호문");
}
@Test
@DisplayName("3-3. AES/ECB/NoPadding — 16 배수 아닌 평문 → 예외")
void testEcb_nonBlockAligned_throwsException() {
byte[] plain = "NotSixteenBytes!X".getBytes(StandardCharsets.UTF_8); // 17바이트
assertThrows(Exception.class,
() -> service.encrypt("AES_ECB_SVC", plain),
"NoPadding 모드에서 블록 크기 미정렬 평문은 예외가 발생해야 한다");
}
// =========================================================================
// 4. STATIC AAD encrypt(name, aad, plain) / decrypt(name, aad, cipher)
// =========================================================================
@Test
@DisplayName("4-1. STATIC AES/GCM — AAD 명시 encrypt → decrypt 라운드트립")
void testStaticGcm_explicitAad_roundTrip() throws Exception {
byte[] aad = "request-header-ctx".getBytes(StandardCharsets.UTF_8);
byte[] plain = "GCM AAD 서비스 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_GCM_SVC", aad, plain);
byte[] decrypted = service.decrypt("AES_GCM_SVC", aad, encrypted);
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("4-2. STATIC AES/GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작")
void testStaticGcm_nullAad_usesIvAsAad() throws Exception {
byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_GCM_SVC", (byte[]) null, plain);
byte[] decrypted = service.decrypt("AES_GCM_SVC", (byte[]) null, encrypted);
assertArrayEquals(plain, decrypted);
String encBase64 = Base64.getEncoder().encodeToString(encrypted);
String decBase64 = Base64.getEncoder().encodeToString(decrypted);
System.out.println("STATIC AES/GCM");
System.out.println("plain:"+new String(plain));
System.out.println("encBase64:"+encBase64);
System.out.println("decBase64:"+decBase64);
System.out.println("plain:"+new String(decrypted));
}
@Test
@DisplayName("4-3. STATIC AES/GCM — 다른 AAD로 복호화 시 인증 실패 예외")
void testStaticGcm_wrongAad_throwsAuthException() throws Exception {
byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8);
byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8);
byte[] plain = "GCM 인증 실패 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_GCM_SVC", aad, plain);
assertThrows(Exception.class,
() -> service.decrypt("AES_GCM_SVC", wrongAad, encrypted),
"잘못된 AAD로 GCM 복호화 시 인증 실패 예외가 발생해야 한다");
}
@Test
@DisplayName("4-4. STATIC CBC — AAD 파라미터는 무시되고 정상 복호화")
void testStaticCbc_aadIgnored_normalDecrypt() throws Exception {
byte[] aad = "ignored-aad".getBytes(StandardCharsets.UTF_8);
byte[] plain = "CBC AAD 무시 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("AES_SVC", aad, plain);
byte[] decrypted = service.decrypt("AES_SVC", aad, encrypted);
assertArrayEquals(plain, decrypted, "CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다");
}
// =========================================================================
// 5. DYNAMIC AAD encrypt(name, ctx, aad, plain) / decrypt(name, ctx, aad, cipher)
// =========================================================================
@Test
@DisplayName("5-1. DYNAMIC AES/GCM — runtimeContext + AAD 명시 encrypt → decrypt 라운드트립")
void testDynamicGcm_explicitAad_roundTrip() throws Exception {
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
byte[] aad = "dynamic-gcm-aad-ctx".getBytes(StandardCharsets.UTF_8);
byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, aad, plain);
byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, aad, encrypted);
String encBase64 = Base64.getEncoder().encodeToString(encrypted);
String decBase64 = Base64.getEncoder().encodeToString(decrypted);
System.out.println("DYNAMIC AES/GCM");
System.out.println("aad:"+new String(aad));
System.out.println("plain:"+new String(plain));
System.out.println("encBase64:"+encBase64);
System.out.println("decBase64:"+decBase64);
System.out.println("plain:"+new String(decrypted));
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("5-2. DYNAMIC AES/GCM — AAD null이면 IV를 AAD로 사용하는 기본 동작")
void testDynamicGcm_nullAad_usesIvAsAad() throws Exception {
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
byte[] plain = "{\"test\": \"테스트\"}".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, null, plain);
byte[] decrypted = service.decrypt("DYN_GCM_SVC", runtimeCtx, null, encrypted);
String encBase64 = Base64.getEncoder().encodeToString(encrypted);
String decBase64 = Base64.getEncoder().encodeToString(decrypted);
System.out.println("DYNAMIC AES/GCM");
System.out.println("aad:null");
System.out.println("plain:"+new String(plain));
System.out.println("encBase64:"+encBase64);
System.out.println("decBase64:"+decBase64);
System.out.println("plain:"+new String(decrypted));
assertArrayEquals(plain, decrypted);
}
@Test
@DisplayName("5-3. DYNAMIC AES/GCM — 다른 AAD로 복호화 시 인증 실패 예외")
void testDynamicGcm_wrongAad_throwsAuthException() throws Exception {
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
byte[] aad = "correct-aad-value".getBytes(StandardCharsets.UTF_8);
byte[] wrongAad = "wrong-aad-value!!".getBytes(StandardCharsets.UTF_8);
byte[] plain = "DYNAMIC GCM 인증 실패".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_GCM_SVC", runtimeCtx, aad, plain);
assertThrows(Exception.class,
() -> service.decrypt("DYN_GCM_SVC", runtimeCtx, wrongAad, encrypted),
"잘못된 AAD로 DYNAMIC GCM 복호화 시 인증 실패 예외가 발생해야 한다");
}
@Test
@DisplayName("5-4. DYNAMIC CBC — AAD 파라미터는 무시되고 정상 복호화")
void testDynamicCbc_aadIgnored_normalDecrypt() throws Exception {
Map<String, String> runtimeCtx = new HashMap<>();
runtimeCtx.put("X-Api-Enc-Key", "clientKey0000001");
byte[] aad = "ignored-aad".getBytes(StandardCharsets.UTF_8);
byte[] plain = "DYNAMIC CBC AAD 무시 테스트".getBytes(StandardCharsets.UTF_8);
byte[] encrypted = service.encrypt("DYN_SVC", runtimeCtx, aad, plain);
byte[] decrypted = service.decrypt("DYN_SVC", runtimeCtx, aad, encrypted);
assertArrayEquals(plain, decrypted, "DYNAMIC CBC 모드에서 AAD는 무시되고 정상 암복호화되어야 한다");
}
// =========================================================================
// 헬퍼
// =========================================================================
private static CryptoModuleConfig buildStatic(String name, String alg, String mode, String padding) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setIvHex(IV_HEX);
c.setKeySourceType("STATIC");
c.setEncKeyHex(ENC_KEY_HEX);
c.setDecKeyHex(ENC_KEY_HEX);
c.setCacheYn("Y");
c.setCacheTtlSec(300);
c.setUseYn("Y");
return c;
}
private static CryptoModuleConfig buildStaticNoIv(String name, String alg, String mode, String padding) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setKeySourceType("STATIC");
c.setEncKeyHex(ENC_KEY_HEX);
c.setDecKeyHex(ENC_KEY_HEX);
c.setCacheYn("Y");
c.setCacheTtlSec(300);
c.setUseYn("Y");
return c;
}
private static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setIvHex(IV_HEX);
c.setKeySourceType("DYNAMIC");
c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy");
c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\"}");
c.setCacheYn("Y");
c.setCacheTtlSec(300);
c.setUseYn("Y");
return c;
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
/** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */
private static CryptoModuleConfigMapper testMapper() {
return new CryptoModuleConfigMapper() {
@Override
public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
vo.setCryptoId(e.getCryptoId());
vo.setCryptoName(e.getCryptoName());
vo.setCryptoDesc(e.getCryptoDesc());
vo.setAlgType(e.getAlgType());
vo.setCipherMode(e.getCipherMode());
vo.setPadding(e.getPadding());
vo.setIvHex(e.getIvHex());
vo.setKeySourceType(e.getKeySourceType());
vo.setEncKeyHex(e.getEncKeyHex());
vo.setDecKeyHex(e.getDecKeyHex());
vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
vo.setKeyDerivParams(e.getKeyDerivParams());
vo.setCacheYn(e.getCacheYn());
vo.setCacheTtlSec(e.getCacheTtlSec());
vo.setUseYn(e.getUseYn());
return vo;
}
@Override
public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
return null;
}
};
}
}
@@ -0,0 +1,39 @@
package com.eactive.eai.common.security.keyderiv;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class DerivedKeyTest {
@Test
@DisplayName("1-1. encKey/decKey 모두 지정 — 각각 독립적으로 반환")
void testBothKeysProvided() {
byte[] encKey = {1, 2, 3};
byte[] decKey = {4, 5, 6};
DerivedKey dk = new DerivedKey(encKey, decKey);
assertArrayEquals(encKey, dk.getEncKey());
assertArrayEquals(decKey, dk.getDecKey());
}
@Test
@DisplayName("1-2. decKey null — encKey로 대체")
void testNullDecKey_fallsBackToEncKey() {
byte[] encKey = {1, 2, 3};
DerivedKey dk = new DerivedKey(encKey, null);
assertArrayEquals(encKey, dk.getEncKey());
assertArrayEquals(encKey, dk.getDecKey(), "decKey가 null이면 encKey와 동일해야 한다");
}
@Test
@DisplayName("1-3. 대칭 알고리즘 — encKey == decKey 동일 참조 허용")
void testSymmetricKey_sameReference() {
byte[] key = {0x10, 0x20, 0x30};
DerivedKey dk = new DerivedKey(key, key);
assertSame(dk.getEncKey(), dk.getDecKey());
}
}
@@ -0,0 +1,179 @@
package com.eactive.eai.common.security.keyderiv;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.keyderiv.DerivedKey;
import com.eactive.eai.common.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HsmContextSha256KeyDerivationStrategy 단위 테스트
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class HsmContextSha256KeyDerivationStrategyTest {
private static final String HSM_KEY_ALIAS = "MASTER_KEY_AES";
private static final byte[] MASTER_KEY = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static GenericApplicationContext ctx;
private static HsmCryptoService mockHsmCryptoService;
private static HsmContextSha256KeyDerivationStrategy strategy;
private Map<String, String> params;
private Map<String, String> runtimeContext;
@BeforeAll
static void setUpClass() throws Exception {
mockHsmCryptoService = mock(HsmCryptoService.class);
SecretKey mockSecretKey = new SecretKeySpec(MASTER_KEY, "AES");
when(mockHsmCryptoService.getSecretKey(HSM_KEY_ALIAS)).thenReturn(mockSecretKey);
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
strategy = new HsmContextSha256KeyDerivationStrategy();
}
@BeforeEach
void setUpParams() {
params = new HashMap<>();
params.put("hsmKeyAlias", HSM_KEY_ALIAS);
params.put("contextKey", "X-Api-Group-Seq");
runtimeContext = new HashMap<>();
runtimeContext.put("X-Api-Group-Seq", "GROUP_001");
}
// =========================================================================
// 1. deriveKey
// =========================================================================
@Test
@DisplayName("1-1. 정상 파라미터 — DerivedKey 반환, 길이 32바이트(SHA-256 고정 출력)")
void testDeriveKey_validParams_returns32ByteKey() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertNotNull(dk);
assertNotNull(dk.getEncKey());
assertNotNull(dk.getDecKey());
assertEquals(32, dk.getEncKey().length, "SHA-256 출력은 항상 32바이트여야 한다");
}
@Test
@DisplayName("1-2. SHA-256(masterKey || contextValue) 계산 결과 검증")
void testDeriveKey_sha256ResultIsCorrect() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
byte[] contextBytes = "GROUP_001".getBytes(StandardCharsets.UTF_8);
byte[] combined = new byte[MASTER_KEY.length + contextBytes.length];
System.arraycopy(MASTER_KEY, 0, combined, 0, MASTER_KEY.length);
System.arraycopy(contextBytes, 0, combined, MASTER_KEY.length, contextBytes.length);
byte[] expected = MessageDigest.getInstance("SHA-256").digest(combined);
assertArrayEquals(expected, dk.getEncKey(), "SHA-256 해싱 결과가 일치해야 한다");
}
@Test
@DisplayName("1-3. 대칭 알고리즘 — encKey == decKey")
void testDeriveKey_encKeyEqualsDecKey() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertArrayEquals(dk.getEncKey(), dk.getDecKey(), "대칭 방식이므로 encKey와 decKey가 동일해야 한다");
}
@Test
@DisplayName("1-4. runtimeContext 값 없음 — 빈 문자열로 처리")
void testDeriveKey_missingContextValue_emptyStringUsed() throws Exception {
runtimeContext.remove("X-Api-Group-Seq");
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
byte[] expected = MessageDigest.getInstance("SHA-256").digest(MASTER_KEY);
assertArrayEquals(expected, dk.getEncKey(), "컨텍스트 값 없으면 masterKey만 해싱해야 한다");
}
@Test
@DisplayName("1-5. 다른 runtimeContext 값 — 다른 DerivedKey 생성")
void testDeriveKey_differentContextValue_differentKey() throws Exception {
DerivedKey dk1 = strategy.deriveKey(params, runtimeContext);
runtimeContext.put("X-Api-Group-Seq", "GROUP_002");
DerivedKey dk2 = strategy.deriveKey(params, runtimeContext);
assertFalse(java.util.Arrays.equals(dk1.getEncKey(), dk2.getEncKey()),
"컨텍스트 값이 다르면 도출된 키도 달라야 한다");
}
@Test
@DisplayName("1-6. 필수 파라미터 누락(hsmKeyAlias) — IllegalArgumentException")
void testDeriveKey_missingHsmKeyAlias_throwsException() {
params.remove("hsmKeyAlias");
assertThrows(IllegalArgumentException.class,
() -> strategy.deriveKey(params, runtimeContext),
"hsmKeyAlias 누락 시 IllegalArgumentException이 발생해야 한다");
}
@Test
@DisplayName("1-7. 필수 파라미터 누락(contextKey) — IllegalArgumentException")
void testDeriveKey_missingContextKey_throwsException() {
params.remove("contextKey");
assertThrows(IllegalArgumentException.class,
() -> strategy.deriveKey(params, runtimeContext),
"contextKey 누락 시 IllegalArgumentException이 발생해야 한다");
}
// =========================================================================
// 2. buildCacheKey
// =========================================================================
@Test
@DisplayName("2-1. buildCacheKey — cryptoName:contextValue 형식")
void testBuildCacheKey_format() {
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
assertEquals("CRYPTO_A:GROUP_001", cacheKey);
}
@Test
@DisplayName("2-2. buildCacheKey — contextKey 값 없으면 빈 문자열")
void testBuildCacheKey_missingContextValue_emptyString() {
runtimeContext.remove("X-Api-Group-Seq");
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
assertEquals("CRYPTO_A:", cacheKey);
}
@Test
@DisplayName("2-3. buildCacheKey — 다른 cryptoName은 다른 캐시 키")
void testBuildCacheKey_differentCryptoName_differentKey() {
String key1 = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
String key2 = strategy.buildCacheKey("CRYPTO_B", params, runtimeContext);
assertNotEquals(key1, key2);
}
}
@@ -0,0 +1,192 @@
package com.eactive.eai.common.security.keyderiv;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* HsmContextXorKeyDerivationStrategy 단위 테스트
* HsmCryptoService는 Mockito Mock으로 대체한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class HsmContextXorKeyDerivationStrategyTest {
private static final String HSM_KEY_ALIAS = "MASTER_KEY_AES";
private static final byte[] MASTER_KEY = "0123456789abcdef".getBytes(java.nio.charset.StandardCharsets.UTF_8);
private static GenericApplicationContext ctx;
private static HsmCryptoService mockHsmCryptoService;
private static HsmContextXorKeyDerivationStrategy strategy;
private Map<String, String> params;
private Map<String, String> runtimeContext;
@BeforeAll
static void setUpClass() throws Exception {
mockHsmCryptoService = mock(HsmCryptoService.class);
SecretKey mockSecretKey = new SecretKeySpec(MASTER_KEY, "AES");
when(mockHsmCryptoService.getSecretKey(HSM_KEY_ALIAS)).thenReturn(mockSecretKey);
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
strategy = new HsmContextXorKeyDerivationStrategy();
}
@BeforeEach
void setUpParams() {
params = new HashMap<>();
params.put("hsmKeyAlias", HSM_KEY_ALIAS);
params.put("contextKey", "X-Api-Enc-Key");
params.put("offset", "0");
params.put("length", "16");
runtimeContext = new HashMap<>();
runtimeContext.put("X-Api-Enc-Key", "clientKeyValue01");
}
// =========================================================================
// 1. deriveKey
// =========================================================================
@Test
@DisplayName("1-1. 정상 파라미터 — DerivedKey 반환")
void testDeriveKey_validParams_returnsDerivedKey() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertNotNull(dk);
assertNotNull(dk.getEncKey());
assertNotNull(dk.getDecKey());
assertEquals(16, dk.getEncKey().length, "도출된 키 길이는 마스터키 길이(16)여야 한다");
}
@Test
@DisplayName("1-2. XOR 계산 결과 검증")
void testDeriveKey_xorResultIsCorrect() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
byte[] contextBytes = "clientKeyValue01".getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] expected = new byte[MASTER_KEY.length];
for (int i = 0; i < MASTER_KEY.length; i++) {
expected[i] = (i < contextBytes.length)
? (byte) (MASTER_KEY[i] ^ contextBytes[i])
: MASTER_KEY[i];
}
assertArrayEquals(expected, dk.getEncKey(), "XOR 계산 결과가 일치해야 한다");
}
@Test
@DisplayName("1-3. 대칭 알고리즘 — encKey == decKey")
void testDeriveKey_encKeyEqualsDecKey() throws Exception {
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertArrayEquals(dk.getEncKey(), dk.getDecKey(), "대칭 방식이므로 encKey와 decKey가 동일해야 한다");
}
@Test
@DisplayName("1-4. runtimeContext 값 없음 — 빈 문자열로 처리 (XOR 불변)")
void testDeriveKey_missingContextValue_noXorChange() throws Exception {
runtimeContext.remove("X-Api-Enc-Key");
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertArrayEquals(MASTER_KEY, dk.getEncKey(), "컨텍스트 값이 없으면 마스터키 그대로 반환되어야 한다");
}
@Test
@DisplayName("1-5. offset 지정 — 해당 위치부터 XOR 적용")
void testDeriveKey_withOffset() throws Exception {
params.put("offset", "4");
params.put("length", "8");
runtimeContext.put("X-Api-Enc-Key", "01234567890abcde");
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
assertNotNull(dk);
assertEquals(MASTER_KEY.length, dk.getEncKey().length);
}
@Test
@DisplayName("1-6. 필수 파라미터 누락(hsmKeyAlias) — IllegalArgumentException")
void testDeriveKey_missingHsmKeyAlias_throwsException() {
params.remove("hsmKeyAlias");
assertThrows(IllegalArgumentException.class,
() -> strategy.deriveKey(params, runtimeContext),
"hsmKeyAlias 누락 시 IllegalArgumentException이 발생해야 한다");
}
@Test
@DisplayName("1-7. 필수 파라미터 누락(contextKey) — IllegalArgumentException")
void testDeriveKey_missingContextKey_throwsException() {
params.remove("contextKey");
assertThrows(IllegalArgumentException.class,
() -> strategy.deriveKey(params, runtimeContext),
"contextKey 누락 시 IllegalArgumentException이 발생해야 한다");
}
@Test
@DisplayName("1-8. 다른 runtimeContext 값 — 다른 DerivedKey 생성")
void testDeriveKey_differentContextValue_differentKey() throws Exception {
DerivedKey dk1 = strategy.deriveKey(params, runtimeContext);
runtimeContext.put("X-Api-Enc-Key", "differentValue00");
DerivedKey dk2 = strategy.deriveKey(params, runtimeContext);
assertFalse(java.util.Arrays.equals(dk1.getEncKey(), dk2.getEncKey()),
"컨텍스트 값이 다르면 도출된 키도 달라야 한다");
}
// =========================================================================
// 2. buildCacheKey
// =========================================================================
@Test
@DisplayName("2-1. buildCacheKey — cryptoName:contextValue 형식")
void testBuildCacheKey_format() {
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
assertEquals("CRYPTO_A:clientKeyValue01", cacheKey);
}
@Test
@DisplayName("2-2. buildCacheKey — contextKey 값 없으면 빈 문자열")
void testBuildCacheKey_missingContextValue_emptyString() {
runtimeContext.remove("X-Api-Enc-Key");
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
assertEquals("CRYPTO_A:", cacheKey);
}
@Test
@DisplayName("2-3. buildCacheKey — 다른 cryptoName은 다른 캐시 키")
void testBuildCacheKey_differentCryptoName_differentKey() {
String key1 = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
String key2 = strategy.buildCacheKey("CRYPTO_B", params, runtimeContext);
assertNotEquals(key1, key2);
}
}
@@ -0,0 +1,3 @@
INSERT INTO inflow_control_group (group_id, group_name, threshold, threshold_per_second, threshold_time_unit, use_yn) VALUES
('test-group-001', '테스트 그룹', 100, 10, 'MIN', '1'),
('test-group-quota', '쿼터전용 그룹', 5, 0, 'MIN', '1');
@@ -0,0 +1,2 @@
INSERT INTO inflow_control_group_mapping (interface_id, group_id, use_yn) VALUES
('FASSFEP00000004S2', 'test-group-001', '1');
@@ -1,6 +1,8 @@
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
('02','FASSFEP00000004S2',10,'1',0,NULL),
('02','FDASFEP00000009S2',2,'1',0,NULL);
INSERT INTO tseaifr09 (type,name,threshold,useyn,thresholdpersecond,thresholdtimeunit) VALUES
('01','_AA0_IN_NET_AsS',10,'1',0,NULL),
('01','_AB0_IN_NET_AsS',3,'1',0,NULL),
('01','_TST_IN_NET_AsS',1,'1',0,NULL),
('02','FASSFEP00000004S2',10,'1',0,NULL),
('02','FDASFEP00000009S2',2,'1',0,NULL),
('01','TEST_ADAPTER',5,'1',0,'MIN'),
('02','TEST_INTERFACE',5,'1',0,'MIN');