diff --git a/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilter.java b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilter.java
new file mode 100644
index 0000000..92c97e2
--- /dev/null
+++ b/src/main/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilter.java
@@ -0,0 +1,263 @@
+package com.eactive.eai.adapter.http.dynamic.filter;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.eactive.eai.common.property.PropManager;
+import com.eactive.eai.common.util.Logger;
+
+/**
+ * 수신 Http Header 중 허용 목록(white list)에 등록된 헤더만 응답 헤더로 복사하는 Inbound Adapter Filter.
+ *
+ *
모든 헤더를 복사하고 제외 목록으로 걸러내는 {@link ReflectAllHeaderFilter} 와 달리,
+ * 복사 대상을 어댑터 그룹별로 명시하는 방식이다.
+ *
+ *
설정은 HttpHeaderFilter property 그룹에 등록하며, 아래 순서로 가장 먼저 찾은 키 하나만 사용한다.
+ * (합집합이 아니라 override 이므로 어댑터 그룹 키를 지정하면 전역 키는 무시된다.)
+ *
+ *
+ * 1. ReflectHeaderFilter.whiteList.{어댑터그룹명} 어댑터 그룹 단위
+ * 2. ReflectHeaderFilter.whiteList 전역 기본값
+ *
+ *
+ * 값은 콤마로 구분한 헤더명 목록이며 대소문자를 구분하지 않는다.
+ * 헤더명 끝에 * 를 붙이면 접두사 일치로 처리한다.
+ *
+ *
+ * HttpHeaderFilter.ReflectHeaderFilter.whiteList = x-elink-client-id
+ * HttpHeaderFilter.ReflectHeaderFilter.whiteList.djbTrans = x-obp-txid, x-obp-partnercode, X-KKB-*
+ *
+ *
+ * 설정이 없거나 비어 있으면 어떤 헤더도 복사하지 않는다.
+ */
+public class ReflectHeaderFilter implements HttpAdapterFilter {
+
+ protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
+
+ public static final String PROPERTIES_GROUP_NAME = "HttpHeaderFilter";
+ public static final String HEADER_KEY_NAMES = "ReflectHeaderFilter.whiteList";
+
+ /**
+ * 허용 목록에 등록되어 있어도 복사하지 않는 헤더.
+ * 요청측 값이 응답 본문/커넥션과 불일치하면 응답 자체가 깨지므로 설정으로 열 수 없게 한다.
+ */
+ private static final Set NEVER_REFLECT = createHeaderSet(
+ "Content-Length",
+ "Transfer-Encoding",
+ "Connection",
+ "Keep-Alive",
+ "Upgrade",
+ "TE",
+ "Trailer");
+
+ /** 어댑터 그룹별 허용 목록 캐시. 필터 인스턴스는 HttpAdapterFilterFactory 에서 싱글톤으로 공유된다. */
+ private final ConcurrentHashMap whiteListCache = new ConcurrentHashMap();
+
+ public ReflectHeaderFilter() {
+ super();
+ }
+
+ @Override
+ public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
+ HttpServletRequest request, HttpServletResponse response) throws Exception {
+ logger.debug("doPreFilter ReflectHeaderFilter Start.");
+
+ reflectHeaders(adptGrpName, request, response);
+
+ logger.debug("doPreFilter ReflectHeaderFilter End.");
+
+ return message;
+ }
+
+ @Override
+ public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
+ HttpServletRequest request, HttpServletResponse response) throws Exception {
+ logger.debug("doPostFilter ReflectHeaderFilter Start.");
+
+ reflectHeaders(adptGrpName, request, response);
+
+ logger.debug("doPostFilter ReflectHeaderFilter End.");
+
+ return resultMessage;
+ }
+
+ private void reflectHeaders(String adptGrpName, HttpServletRequest request, HttpServletResponse response) {
+
+ WhiteList whiteList = getWhiteList(adptGrpName);
+
+ if (whiteList.isEmpty()) {
+ logger.debug("No reflect header configured for adapter group [" + adptGrpName + "]. Skip all.");
+ return;
+ }
+
+ try {
+ for (String headerName : whiteList.getNames()) {
+
+ String headerValue = request.getHeader(headerName);
+ if (headerValue == null) {
+ continue;
+ }
+
+ setHeader(headerName, headerValue, response);
+ }
+
+ // 접두사(*) 설정이 있을 때만 수신 헤더를 순회한다.
+ if (whiteList.hasPrefix()) {
+ java.util.Enumeration headerNames = request.getHeaderNames();
+ while (headerNames != null && headerNames.hasMoreElements()) {
+
+ String headerName = headerNames.nextElement();
+ if (!whiteList.matchesPrefix(headerName)) {
+ continue;
+ }
+
+ setHeader(headerName, request.getHeader(headerName), response);
+ }
+ }
+
+ } catch (Exception e) {
+ logger.error(e.getMessage());
+ }
+ }
+
+ private void setHeader(String headerName, String headerValue, HttpServletResponse response) {
+
+ if (NEVER_REFLECT.contains(headerName)) {
+ logger.debug("Skip Processing Key [" + headerName + "] in NEVER_REFLECT list");
+ return;
+ }
+
+ // 헤더명/값에 CR, LF 가 있으면 응답 분할(response splitting) 위험이 있다.
+ if (containsCrLf(headerName) || containsCrLf(headerValue)) {
+ logger.error("Skip Processing Key [" + headerName + "] - CR/LF detected in header name or value");
+ return;
+ }
+
+ response.setHeader(headerName, headerValue);
+ logger.debug("Processing Key [" + headerName + "], value [" + headerValue + "]");
+ }
+
+ private boolean containsCrLf(String value) {
+ return value != null && (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0);
+ }
+
+ /**
+ * 어댑터 그룹 → 전역 순으로 property 를 찾아 허용 목록을 반환한다.
+ * property 값이 바뀌지 않는 동안은 파싱 결과를 재사용한다.
+ */
+ private WhiteList getWhiteList(String adptGrpName) {
+
+ String cacheKey = StringUtils.defaultString(adptGrpName);
+ String propValue = findPropValue(adptGrpName);
+
+ CachedWhiteList cached = whiteListCache.get(cacheKey);
+ if (cached != null && StringUtils.equals(cached.propValue, propValue)) {
+ return cached.whiteList;
+ }
+
+ WhiteList whiteList = parse(propValue);
+ whiteListCache.put(cacheKey, new CachedWhiteList(propValue, whiteList));
+ return whiteList;
+ }
+
+ private String findPropValue(String adptGrpName) {
+
+ PropManager propManager = PropManager.getInstance();
+
+ if (StringUtils.isNotBlank(adptGrpName)) {
+ String value = propManager.getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES + "." + adptGrpName, "");
+ if (StringUtils.isNotBlank(value)) {
+ return StringUtils.trimToEmpty(value);
+ }
+ }
+
+ return StringUtils.trimToEmpty(propManager.getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES, ""));
+ }
+
+ private static WhiteList parse(String propValue) {
+
+ Set names = new TreeSet(String.CASE_INSENSITIVE_ORDER);
+ List prefixes = new ArrayList();
+
+ if (StringUtils.isNotBlank(propValue)) {
+ for (String token : propValue.split(",")) {
+ String name = StringUtils.trimToEmpty(token);
+ if (name.isEmpty()) {
+ continue;
+ }
+
+ if (name.endsWith("*")) {
+ String prefix = name.substring(0, name.length() - 1);
+ if (!prefix.isEmpty()) {
+ prefixes.add(prefix);
+ }
+ continue;
+ }
+
+ names.add(name);
+ }
+ }
+
+ return new WhiteList(names, prefixes);
+ }
+
+ private static Set createHeaderSet(String... names) {
+ Set set = new TreeSet(String.CASE_INSENSITIVE_ORDER);
+ Collections.addAll(set, names);
+ return Collections.unmodifiableSet(set);
+ }
+
+ /** 파싱된 허용 목록. 헤더명 완전일치 목록과 접두사(*) 목록으로 구성된다. */
+ private static class WhiteList {
+
+ private final Set names;
+ private final List prefixes;
+
+ WhiteList(Set names, List prefixes) {
+ this.names = Collections.unmodifiableSet(names);
+ this.prefixes = Collections.unmodifiableList(prefixes);
+ }
+
+ boolean isEmpty() {
+ return names.isEmpty() && prefixes.isEmpty();
+ }
+
+ Set getNames() {
+ return names;
+ }
+
+ boolean hasPrefix() {
+ return !prefixes.isEmpty();
+ }
+
+ boolean matchesPrefix(String headerName) {
+ for (String prefix : prefixes) {
+ if (StringUtils.startsWithIgnoreCase(headerName, prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private static class CachedWhiteList {
+
+ private final String propValue;
+ private final WhiteList whiteList;
+
+ CachedWhiteList(String propValue, WhiteList whiteList) {
+ this.propValue = propValue;
+ this.whiteList = whiteList;
+ }
+ }
+}
diff --git a/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilterTest.java b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilterTest.java
new file mode 100644
index 0000000..afd5d5d
--- /dev/null
+++ b/src/test/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilterTest.java
@@ -0,0 +1,549 @@
+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.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+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.property.PropManager;
+import com.eactive.eai.common.util.ApplicationContextProvider;
+
+/**
+ * ReflectHeaderFilter 단위 테스트.
+ *
+ * 복사 대상 헤더 목록은 PropManager(그룹: HttpHeaderFilter,
+ * 키: ReflectHeaderFilter.whiteList[.어댑터그룹명])에서 조회하므로
+ * PropManager 를 Mock 으로 등록하고 필터 로직만 검증한다.
+ */
+@TestMethodOrder(MethodOrderer.DisplayName.class)
+class ReflectHeaderFilterTest {
+
+ private static final String GRP = "TEST_GRP";
+ private static final String ADPT = "TEST_ADPT";
+ private static final String PROP_GROUP = ReflectHeaderFilter.PROPERTIES_GROUP_NAME;
+ private static final String KEY = ReflectHeaderFilter.HEADER_KEY_NAMES;
+ private static final String KEY_GRP = KEY + "." + GRP;
+
+ private static GenericApplicationContext ctx;
+ private static PropManager mockPropManager;
+
+ private ReflectHeaderFilter filter;
+ private HttpServletRequest mockRequest;
+ private HttpServletResponse mockResponse;
+ private Properties prop;
+
+ @BeforeAll
+ static void setUpClass() {
+ mockPropManager = mock(PropManager.class);
+
+ ctx = new GenericApplicationContext();
+ ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
+ ctx.registerBeanDefinition("applicationContextProvider",
+ BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
+ .getBeanDefinition());
+ ctx.refresh();
+ }
+
+ @AfterAll
+ static void tearDownClass() {
+ if (ctx != null) ctx.close();
+ }
+
+ @BeforeEach
+ void setUp() {
+ reset(mockPropManager);
+ mockRequest = mock(HttpServletRequest.class);
+ mockResponse = mock(HttpServletResponse.class);
+ prop = new Properties();
+
+ // 미설정 키는 실제 PropManager 와 동일하게 기본값을 반환한다.
+ when(mockPropManager.getProperty(anyString(), anyString(), anyString()))
+ .thenAnswer(inv -> inv.getArgument(2));
+
+ // 필터별 캐시가 테스트간에 섞이지 않도록 매번 새 인스턴스를 사용한다.
+ filter = new ReflectHeaderFilter();
+ }
+
+ /** 프로퍼티 설정 - 어댑터 그룹 단위 키. */
+ private void givenGroupWhiteList(String value) {
+ when(mockPropManager.getProperty(PROP_GROUP, KEY_GRP, "")).thenReturn(value);
+ }
+
+ /** 프로퍼티 설정 - 전역 키. */
+ private void givenGlobalWhiteList(String value) {
+ when(mockPropManager.getProperty(PROP_GROUP, KEY, "")).thenReturn(value);
+ }
+
+ /** 수신 요청 헤더 설정. getHeader 는 서블릿 스펙대로 대소문자를 구분하지 않는다. */
+ private void givenRequestHeaders(String... nameValuePairs) {
+ Map headers = new LinkedHashMap<>();
+ for (int i = 0; i < nameValuePairs.length; i += 2) {
+ headers.put(nameValuePairs[i], nameValuePairs[i + 1]);
+ }
+
+ when(mockRequest.getHeader(anyString())).thenAnswer(inv -> {
+ String wanted = inv.getArgument(0);
+ for (Map.Entry e : headers.entrySet()) {
+ if (e.getKey().equalsIgnoreCase(wanted)) return e.getValue();
+ }
+ return null;
+ });
+ // doPreFilter/doPostFilter 가 각각 순회할 수 있도록 호출마다 새 Enumeration 을 반환한다.
+ when(mockRequest.getHeaderNames())
+ .thenAnswer(inv -> Collections.enumeration(new ArrayList<>(headers.keySet())));
+ }
+
+ // =========================================================================
+ // 1. 허용 목록에 등록된 헤더만 복사
+ // =========================================================================
+
+ @Test
+ @DisplayName("1-1. 허용 목록에 등록된 헤더만 응답으로 복사한다")
+ void testWhiteListedHeadersOnly() throws Exception {
+ givenGroupWhiteList("x-obp-txid, x-elink-client-id");
+ givenRequestHeaders(
+ "x-obp-txid", "TX-001",
+ "x-elink-client-id", "CLIENT-A",
+ "Authorization", "Bearer secret",
+ "Cookie", "JSESSIONID=abc");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-001");
+ verify(mockResponse).setHeader("x-elink-client-id", "CLIENT-A");
+ verify(mockResponse, never()).setHeader(eq("Authorization"), anyString());
+ verify(mockResponse, never()).setHeader(eq("Cookie"), anyString());
+ verifyNoMoreInteractions(mockResponse);
+ }
+
+ @Test
+ @DisplayName("1-2. 헤더명 대소문자를 구분하지 않고 매칭한다")
+ void testCaseInsensitiveMatch() throws Exception {
+ givenGroupWhiteList("X-OBP-TXID");
+ givenRequestHeaders("x-obp-txid", "TX-002");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("X-OBP-TXID", "TX-002");
+ }
+
+ @Test
+ @DisplayName("1-3. 허용 목록에 있어도 수신 요청에 없는 헤더는 복사하지 않는다")
+ void testAbsentHeaderNotReflected() throws Exception {
+ givenGroupWhiteList("x-obp-txid, x-not-sent");
+ givenRequestHeaders("x-obp-txid", "TX-003");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-003");
+ verify(mockResponse, never()).setHeader(eq("x-not-sent"), any());
+ }
+
+ @Test
+ @DisplayName("1-4. 값이 빈 문자열인 헤더도 복사한다")
+ void testEmptyValueReflected() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "");
+ }
+
+ @Test
+ @DisplayName("1-5. 목록의 공백/빈 항목은 무시한다")
+ void testBlankTokensIgnored() throws Exception {
+ givenGroupWhiteList(" x-obp-txid , , , ");
+ givenRequestHeaders("x-obp-txid", "TX-005");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-005");
+ verifyNoMoreInteractions(mockResponse);
+ }
+
+ // =========================================================================
+ // 2. 프로퍼티 조회 우선순위 (어댑터 그룹 → 전역)
+ // =========================================================================
+
+ @Test
+ @DisplayName("2-1. 어댑터 그룹 키가 있으면 전역 키를 무시한다(override)")
+ void testGroupKeyOverridesGlobal() throws Exception {
+ givenGlobalWhiteList("x-global-only");
+ givenGroupWhiteList("x-group-only");
+ givenRequestHeaders(
+ "x-global-only", "G",
+ "x-group-only", "S");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-group-only", "S");
+ verify(mockResponse, never()).setHeader(eq("x-global-only"), anyString());
+ }
+
+ @Test
+ @DisplayName("2-2. 어댑터 그룹 키가 없으면 전역 키를 사용한다")
+ void testFallbackToGlobalKey() throws Exception {
+ givenGlobalWhiteList("x-global-only");
+ givenRequestHeaders("x-global-only", "G");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-global-only", "G");
+ }
+
+ @Test
+ @DisplayName("2-3. 어댑터 그룹 키가 공백이면 전역 키로 폴백한다")
+ void testBlankGroupKeyFallsBackToGlobal() throws Exception {
+ givenGroupWhiteList(" ");
+ givenGlobalWhiteList("x-global-only");
+ givenRequestHeaders("x-global-only", "G");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-global-only", "G");
+ }
+
+ @Test
+ @DisplayName("2-4. 어댑터 그룹마다 다른 목록을 적용한다")
+ void testPerGroupWhiteList() throws Exception {
+ when(mockPropManager.getProperty(PROP_GROUP, KEY + ".GRP_A", "")).thenReturn("x-a");
+ when(mockPropManager.getProperty(PROP_GROUP, KEY + ".GRP_B", "")).thenReturn("x-b");
+ givenRequestHeaders("x-a", "A", "x-b", "B");
+
+ filter.doPreFilter("GRP_A", ADPT, "msg", prop, mockRequest, mockResponse);
+ verify(mockResponse).setHeader("x-a", "A");
+ verify(mockResponse, never()).setHeader(eq("x-b"), anyString());
+
+ reset(mockResponse);
+
+ filter.doPreFilter("GRP_B", ADPT, "msg", prop, mockRequest, mockResponse);
+ verify(mockResponse).setHeader("x-b", "B");
+ verify(mockResponse, never()).setHeader(eq("x-a"), anyString());
+ }
+
+ @Test
+ @DisplayName("2-5. 어댑터 그룹명이 null 이어도 전역 키로 동작한다")
+ void testNullGroupName() throws Exception {
+ givenGlobalWhiteList("x-global-only");
+ givenRequestHeaders("x-global-only", "G");
+
+ filter.doPreFilter(null, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-global-only", "G");
+ }
+
+ @Test
+ @DisplayName("2-6. 어댑터명은 프로퍼티 키 조회에 사용하지 않는다")
+ void testAdapterNameNotUsedInKeyLookup() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-006");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockPropManager, never()).getProperty(eq(PROP_GROUP), contains(ADPT), anyString());
+ }
+
+ // =========================================================================
+ // 3. 미설정 시 아무 헤더도 복사하지 않음
+ // =========================================================================
+
+ @Test
+ @DisplayName("3-1. 설정이 없으면 어떤 헤더도 복사하지 않는다")
+ void testNoConfigReflectsNothing() throws Exception {
+ givenRequestHeaders("x-obp-txid", "TX-007", "Authorization", "Bearer secret");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ @Test
+ @DisplayName("3-2. 설정값이 콤마뿐이어도 예외 없이 아무것도 복사하지 않는다")
+ void testCommaOnlyConfigReflectsNothing() throws Exception {
+ givenGroupWhiteList(",,,");
+ givenRequestHeaders("x-obp-txid", "TX-008");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ // =========================================================================
+ // 4. 접두사(*) 매칭
+ // =========================================================================
+
+ @Test
+ @DisplayName("4-1. 접두사(*) 설정은 해당 접두사로 시작하는 수신 헤더를 모두 복사한다")
+ void testPrefixMatch() throws Exception {
+ givenGroupWhiteList("X-KKB-*");
+ givenRequestHeaders(
+ "X-KKB-API-NAME", "transfer",
+ "X-KKB-API-TX-ID", "TX-009",
+ "X-OBP-TXID", "OTHER");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
+ verify(mockResponse).setHeader("X-KKB-API-TX-ID", "TX-009");
+ verify(mockResponse, never()).setHeader(eq("X-OBP-TXID"), anyString());
+ }
+
+ @Test
+ @DisplayName("4-2. 접두사 매칭도 대소문자를 구분하지 않는다")
+ void testPrefixMatchCaseInsensitive() throws Exception {
+ givenGroupWhiteList("x-kkb-*");
+ givenRequestHeaders("X-KKB-API-NAME", "transfer");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
+ }
+
+ @Test
+ @DisplayName("4-3. 완전일치와 접두사를 함께 설정할 수 있다")
+ void testExactAndPrefixTogether() throws Exception {
+ givenGroupWhiteList("x-obp-txid, X-KKB-*");
+ givenRequestHeaders(
+ "x-obp-txid", "TX-010",
+ "X-KKB-API-NAME", "transfer",
+ "Authorization", "Bearer secret");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-010");
+ verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
+ verify(mockResponse, never()).setHeader(eq("Authorization"), anyString());
+ }
+
+ @Test
+ @DisplayName("4-4. 접두사 설정이 없으면 수신 헤더 전체를 순회하지 않는다")
+ void testNoPrefixSkipsHeaderNamesScan() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-011");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockRequest, never()).getHeaderNames();
+ }
+
+ @Test
+ @DisplayName("4-5. '*' 단독 설정은 접두사가 비어 무시된다")
+ void testBareAsteriskIgnored() throws Exception {
+ givenGroupWhiteList("*");
+ givenRequestHeaders("x-obp-txid", "TX-012");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ @Test
+ @DisplayName("4-6. getHeaderNames() 가 null 이어도 예외가 발생하지 않는다")
+ void testNullHeaderNamesEnumeration() throws Exception {
+ givenGroupWhiteList("X-KKB-*");
+ when(mockRequest.getHeaderNames()).thenReturn(null);
+
+ assertDoesNotThrow(() -> filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse));
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ // =========================================================================
+ // 5. 복사 금지 헤더 / CRLF 방어
+ // =========================================================================
+
+ @Test
+ @DisplayName("5-1. 응답 프레이밍 헤더는 허용 목록에 있어도 복사하지 않는다")
+ void testNeverReflectHeaders() throws Exception {
+ givenGroupWhiteList("Content-Length, Transfer-Encoding, Connection, Keep-Alive, Upgrade, TE, Trailer, x-obp-txid");
+ givenRequestHeaders(
+ "Content-Length", "100",
+ "Transfer-Encoding", "chunked",
+ "Connection", "keep-alive",
+ "Keep-Alive", "timeout=5",
+ "Upgrade", "websocket",
+ "TE", "trailers",
+ "Trailer", "Expires",
+ "x-obp-txid", "TX-013");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-013");
+ verifyNoMoreInteractions(mockResponse);
+ }
+
+ @Test
+ @DisplayName("5-2. 복사 금지 헤더 판정도 대소문자를 구분하지 않는다")
+ void testNeverReflectCaseInsensitive() throws Exception {
+ givenGroupWhiteList("content-length");
+ givenRequestHeaders("content-length", "100");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ @Test
+ @DisplayName("5-3. 접두사 매칭으로 걸린 헤더도 복사 금지 목록이 우선한다")
+ void testNeverReflectAppliesToPrefixMatch() throws Exception {
+ givenGroupWhiteList("Content-*");
+ givenRequestHeaders("Content-Length", "100", "Content-MD5", "abc");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse, never()).setHeader(eq("Content-Length"), anyString());
+ verify(mockResponse).setHeader("Content-MD5", "abc");
+ }
+
+ @Test
+ @DisplayName("5-4. 값에 CR/LF 가 있으면 응답 분할 방지를 위해 복사하지 않는다")
+ void testCrLfInValueBlocked() throws Exception {
+ givenGroupWhiteList("x-obp-txid, x-safe");
+ givenRequestHeaders(
+ "x-obp-txid", "TX\r\nX-Injected: 1",
+ "x-safe", "OK");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse, never()).setHeader(eq("x-obp-txid"), anyString());
+ verify(mockResponse).setHeader("x-safe", "OK");
+ }
+
+ @Test
+ @DisplayName("5-5. LF 단독, CR 단독 값도 차단한다")
+ void testLoneCrOrLfBlocked() throws Exception {
+ givenGroupWhiteList("x-lf, x-cr");
+ givenRequestHeaders("x-lf", "a\nb", "x-cr", "a\rb");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verifyNoInteractions(mockResponse);
+ }
+
+ // =========================================================================
+ // 6. doPreFilter / doPostFilter 반환값
+ // =========================================================================
+
+ @Test
+ @DisplayName("6-1. doPreFilter 는 원 메시지를 그대로 반환한다")
+ void testDoPreFilterReturnsMessage() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-014");
+
+ Object message = "request-body";
+ Object result = filter.doPreFilter(GRP, ADPT, message, prop, mockRequest, mockResponse);
+
+ assertSame(message, result);
+ }
+
+ @Test
+ @DisplayName("6-2. doPostFilter 도 헤더를 복사하고 원 메시지를 그대로 반환한다")
+ void testDoPostFilterReflectsAndReturnsMessage() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-015");
+
+ Object message = "response-body";
+ Object result = filter.doPostFilter(GRP, ADPT, message, prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-015");
+ assertSame(message, result);
+ }
+
+ @Test
+ @DisplayName("6-3. setHeader 사용으로 pre/post 연속 호출 시 헤더가 중복되지 않는다")
+ void testPreAndPostUseSetHeader() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-016");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+ filter.doPostFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse, times(2)).setHeader("x-obp-txid", "TX-016");
+ verify(mockResponse, never()).addHeader(anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("6-4. 응답 헤더 설정 중 예외가 발생해도 필터는 메시지를 반환한다")
+ void testExceptionSwallowed() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-017");
+ doThrow(new IllegalStateException("response already committed"))
+ .when(mockResponse).setHeader(anyString(), anyString());
+
+ Object result = filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ assertEquals("msg", result);
+ }
+
+ // =========================================================================
+ // 7. 프로퍼티 캐시
+ // =========================================================================
+
+ @Test
+ @DisplayName("7-1. 같은 설정으로 반복 호출해도 동작이 동일하다(캐시 재사용)")
+ void testCacheReuse() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-018");
+
+ for (int i = 0; i < 3; i++) {
+ reset(mockResponse);
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+
+ verify(mockResponse).setHeader("x-obp-txid", "TX-018");
+ verifyNoMoreInteractions(mockResponse);
+ }
+ }
+
+ @Test
+ @DisplayName("7-2. 프로퍼티가 변경되면 다음 호출에 즉시 반영된다")
+ void testCacheInvalidatedOnPropertyChange() throws Exception {
+ givenGroupWhiteList("x-first");
+ givenRequestHeaders("x-first", "F", "x-second", "S");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+ verify(mockResponse).setHeader("x-first", "F");
+
+ reset(mockResponse);
+ givenGroupWhiteList("x-second");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+ verify(mockResponse).setHeader("x-second", "S");
+ verify(mockResponse, never()).setHeader(eq("x-first"), anyString());
+ }
+
+ @Test
+ @DisplayName("7-3. 설정이 제거되면 다음 호출부터 아무것도 복사하지 않는다")
+ void testCacheInvalidatedOnPropertyRemoval() throws Exception {
+ givenGroupWhiteList("x-obp-txid");
+ givenRequestHeaders("x-obp-txid", "TX-019");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+ verify(mockResponse).setHeader("x-obp-txid", "TX-019");
+
+ reset(mockResponse);
+ givenGroupWhiteList("");
+
+ filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
+ verifyNoInteractions(mockResponse);
+ }
+}