From cc016d9d81ae1a3b25ed93bd1c151e3e942196a7 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 14 Aug 2026 15:19:46 +0900 Subject: [PATCH] =?UTF-8?q?ReflectHeaderFilter=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dynamic/filter/ReflectHeaderFilter.java | 263 +++++++++ .../filter/ReflectHeaderFilterTest.java | 549 ++++++++++++++++++ 2 files changed, 812 insertions(+) create mode 100644 src/main/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilter.java create mode 100644 src/test/java/com/eactive/eai/adapter/http/dynamic/filter/ReflectHeaderFilterTest.java 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); + } +}