Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e208a2d64b | |||
| 444b7ad595 | |||
| 085dd024d8 | |||
| 3b6b3f6e4c | |||
| f4c28e8a27 | |||
| 3cda2873cc | |||
| c0efdd89d4 | |||
| 3eac4eeaa6 | |||
| c0c3f45d18 | |||
| 2686448791 | |||
| 3d4e5762c8 | |||
| f7859a77dc |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,35 +23,37 @@ import com.eactive.eai.util.JsonPathUtil;
|
||||
* (CryptoModuleManager가 KEY_SOURCE_TYPE=STATIC이면 runtimeContext를 무시하고 고정 키를 사용한다.)
|
||||
*
|
||||
* 어댑터 프로퍼티 키:
|
||||
* crypto.module.name 필수. DB에 등록된 암호화 모듈명.
|
||||
* crypto.scope BODY | FIELD (기본 BODY)
|
||||
* CRYPTO_MODULE_NAME 필수. DB에 등록된 암호화 모듈명.
|
||||
* CRYPTO_SCOPE BODY | FIELD (기본 FIELD)
|
||||
* BODY : 메시지 전체를 암복호화 (Base64 인코딩/디코딩)
|
||||
* FIELD : 특정 JSON 경로의 값만 암복호화
|
||||
* crypto.dec.enabled Y | N (기본 Y). doPreFilter(요청) 복호화 수행 여부.
|
||||
* crypto.enc.enabled Y | N (기본 N). doPostFilter(응답) 암호화 수행 여부.
|
||||
*
|
||||
* FIELD 범위 전용:
|
||||
* crypto.dec.from.path 복호화 대상 JSON 경로 (예: /encryptedData)
|
||||
* crypto.dec.to.path 복호화 결과 반영 경로. "/" = 전체 body 교체.
|
||||
* crypto.enc.from.path 암호화 대상 JSON 경로. "/" = 전체 body 암호화.
|
||||
* crypto.enc.to.path 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encryptedData)
|
||||
* 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_ENABLED = "crypto.dec.enabled";
|
||||
public static final String PROP_ENC_ENABLED = "crypto.enc.enabled";
|
||||
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_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";
|
||||
|
||||
protected static final String SCOPE_BODY = "BODY";
|
||||
protected static final String SCOPE_FIELD = "FIELD";
|
||||
protected static final String PATH_ROOT = "/";
|
||||
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";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 추상 메서드
|
||||
@@ -71,21 +73,18 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
|
||||
HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||
|
||||
if (!isEnabled(prop, PROP_DEC_ENABLED, "Y")) {
|
||||
return message;
|
||||
}
|
||||
|
||||
String moduleName = required(prop, PROP_MODULE_NAME);
|
||||
String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase();
|
||||
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,
|
||||
required(prop, PROP_DEC_FROM_PATH),
|
||||
prop.getProperty(PROP_DEC_TO_PATH, PATH_ROOT));
|
||||
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);
|
||||
return decryptBody(body, moduleName, runtimeCtx, aad);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -96,30 +95,27 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
|
||||
HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||
|
||||
if (!isEnabled(prop, PROP_ENC_ENABLED, "N")) {
|
||||
return resultMessage;
|
||||
}
|
||||
|
||||
String moduleName = required(prop, PROP_MODULE_NAME);
|
||||
String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase();
|
||||
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,
|
||||
prop.getProperty(PROP_ENC_FROM_PATH, PATH_ROOT),
|
||||
required(prop, PROP_ENC_TO_PATH));
|
||||
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);
|
||||
return encryptBody(body, moduleName, runtimeCtx, aad);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 복호화 구현
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private String decryptBody(String body, String moduleName, Map<String, String> runtimeCtx) throws Exception {
|
||||
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, cipherBytes);
|
||||
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
|
||||
return new String(plainBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -127,7 +123,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
* fromPath의 Base64 값을 복호화하여 toPath에 반영.
|
||||
* toPath = "/" → 복호화된 텍스트로 body 전체 교체.
|
||||
*/
|
||||
private String decryptField(String body, String moduleName, Map<String, String> runtimeCtx,
|
||||
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);
|
||||
@@ -137,7 +133,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
}
|
||||
|
||||
byte[] cipherBytes = Base64.getDecoder().decode(encBase64.trim());
|
||||
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, cipherBytes);
|
||||
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
|
||||
String plainText = new String(plainBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (PATH_ROOT.equals(toPath)) {
|
||||
@@ -150,8 +146,8 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
// 암호화 구현
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private String encryptBody(String body, String moduleName, Map<String, String> runtimeCtx) throws Exception {
|
||||
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx,
|
||||
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);
|
||||
}
|
||||
@@ -160,7 +156,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
* fromPath 값을 암호화하여 toPath(Base64)에 반영.
|
||||
* fromPath = "/" → body 전체를 암호화하여 toPath 필드명으로 래핑.
|
||||
*/
|
||||
private String encryptField(String body, String moduleName, Map<String, String> runtimeCtx,
|
||||
private String encryptField(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad,
|
||||
String fromPath, String toPath) throws Exception {
|
||||
|
||||
String plainText;
|
||||
@@ -174,7 +170,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
}
|
||||
}
|
||||
|
||||
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx,
|
||||
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
|
||||
plainText.getBytes(StandardCharsets.UTF_8));
|
||||
String encBase64 = Base64.getEncoder().encodeToString(cipherBytes);
|
||||
|
||||
@@ -208,8 +204,17 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
return message.toString();
|
||||
}
|
||||
|
||||
private boolean isEnabled(Properties prop, String key, String defaultVal) {
|
||||
return !"N".equalsIgnoreCase(prop.getProperty(key, defaultVal));
|
||||
/**
|
||||
* 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) {
|
||||
@@ -219,4 +224,9 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private String getProp(Properties prop, String key, String defaultVal) {
|
||||
String v = prop.getProperty(key);
|
||||
return StringUtils.isBlank(v) ? defaultVal : v;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+725
@@ -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;
|
||||
}
|
||||
}
|
||||
+464
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -52,13 +53,16 @@ class CryptoModuleServiceTest {
|
||||
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");
|
||||
when(mockLoader.findAll()).thenReturn(Arrays.asList(aes, aria, ecb, dyn));
|
||||
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);
|
||||
@@ -200,6 +204,155 @@ class CryptoModuleServiceTest {
|
||||
"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는 무시되고 정상 암복호화되어야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼
|
||||
// =========================================================================
|
||||
@@ -246,8 +399,8 @@ class CryptoModuleServiceTest {
|
||||
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.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy");
|
||||
c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\"}");
|
||||
c.setCacheYn("Y");
|
||||
c.setCacheTtlSec(300);
|
||||
c.setUseYn("Y");
|
||||
|
||||
+179
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user