package com.eactive.eai.common.security; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Optional; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.context.support.GenericApplicationContext; import com.eactive.eai.common.hsm.HsmCryptoService; import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader; import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper; import com.eactive.eai.common.util.ApplicationContextProvider; import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig; /** * CryptoModuleManager 단위 테스트 * DB / 실 Spring 컨텍스트 없이 Mock으로 동작한다. */ @TestMethodOrder(MethodOrderer.DisplayName.class) class CryptoModuleManagerTest { private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8); private static final byte[] IV_16 = "abcdef0123456789".getBytes(StandardCharsets.UTF_8); private static final String ENC_KEY_HEX = toHex(KEY_128); private static final String IV_HEX = toHex(IV_16); private static GenericApplicationContext ctx; private static CryptoModuleConfigLoader mockLoader; private static HsmCryptoService mockHsmCryptoService; private static CryptoModuleManager manager; @BeforeAll static void setUpClass() throws Exception { mockLoader = mock(CryptoModuleConfigLoader.class); mockHsmCryptoService = mock(HsmCryptoService.class); SecretKey masterKey = new SecretKeySpec(KEY_128, "AES"); when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey); when(mockLoader.findAll()).thenReturn(Collections.emptyList()); Constructor ctor = CryptoModuleManager.class.getDeclaredConstructor(); ctor.setAccessible(true); manager = ctor.newInstance(); ctx = new GenericApplicationContext(); ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader); ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService); ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager); ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper()); ctx.registerBeanDefinition("applicationContextProvider", BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class) .getBeanDefinition()); ctx.refresh(); manager.start(); } @AfterAll static void tearDownClass() { if (ctx != null) ctx.close(); } @BeforeEach void resetMocks() { reset(mockLoader); when(mockLoader.findAll()).thenReturn(Collections.emptyList()); } // ========================================================================= // 1. start / stop Lifecycle // ========================================================================= @Test @DisplayName("1-1. start() 후 isStarted() = true") void testIsStarted() { assertTrue(manager.isStarted()); } // ========================================================================= // 2. STATIC 키 — createExtension // ========================================================================= @Test @DisplayName("2-1. STATIC AES/CBC — createExtension 반환 및 암복호화 동작") void testCreateExtension_staticAesCbc_roundTrip() throws Exception { CryptoModuleConfig config = buildStaticConfig("AES_CBC", "AES", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); byte[] plain = "테스트 평문".getBytes(StandardCharsets.UTF_8); CryptoModuleExtension ext = manager.createExtension("AES_CBC"); byte[] encrypted = ext.encrypt(plain, 0, plain.length); CryptoModuleExtension extDec = manager.createExtension("AES_CBC"); byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); assertArrayEquals(plain, decrypted, "STATIC AES/CBC 암복호화 라운드트립 성공해야 한다"); } @Test @DisplayName("2-2. STATIC ARIA/CBC — createExtension 반환 및 암복호화 동작") void testCreateExtension_staticAriaCbc_roundTrip() throws Exception { CryptoModuleConfig config = buildStaticConfig("ARIA_CBC", "ARIA", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); byte[] plain = "ARIA 테스트".getBytes(StandardCharsets.UTF_8); CryptoModuleExtension ext = manager.createExtension("ARIA_CBC"); byte[] encrypted = ext.encrypt(plain, 0, plain.length); CryptoModuleExtension extDec = manager.createExtension("ARIA_CBC"); byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); assertArrayEquals(plain, decrypted); } @Test @DisplayName("2-3. 미등록 cryptoName — IllegalArgumentException") void testCreateExtension_unknownName_throwsException() { assertThrows(IllegalArgumentException.class, () -> manager.createExtension("UNKNOWN_MODULE"), "미등록 cryptoName은 IllegalArgumentException이어야 한다"); } @Test @DisplayName("2-4. use_yn=N 설정 — configMap 미등록") void testLoadAll_disabledConfig_notRegistered() throws Exception { CryptoModuleConfig config = buildStaticConfig("DISABLED_MOD", "AES", "CBC", "PKCS5Padding"); config.setUseYn("N"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); assertThrows(IllegalArgumentException.class, () -> manager.createExtension("DISABLED_MOD"), "use_yn=N 설정은 로드되지 않아야 한다"); } // ========================================================================= // 3. DYNAMIC 키 — createExtension with runtimeContext // ========================================================================= @Test @DisplayName("3-1. DYNAMIC AES/CBC — runtimeContext 전달 시 암복호화 동작") void testCreateExtension_dynamicAesCbc_roundTrip() throws Exception { CryptoModuleConfig config = buildDynamicConfig("DYN_AES_CBC", "AES", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); Map ctx = new HashMap<>(); ctx.put("X-Api-Enc-Key", "clientKeyValue01"); byte[] plain = "DYNAMIC 키 테스트".getBytes(StandardCharsets.UTF_8); CryptoModuleExtension ext = manager.createExtension("DYN_AES_CBC", ctx); byte[] encrypted = ext.encrypt(plain, 0, plain.length); CryptoModuleExtension extDec = manager.createExtension("DYN_AES_CBC", ctx); byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length); assertArrayEquals(plain, decrypted, "DYNAMIC AES/CBC 암복호화 라운드트립 성공해야 한다"); } @Test @DisplayName("3-2. DYNAMIC — 동일 runtimeContext로 2회 호출 시 HSM은 1회만 호출 (캐시 적용)") void testCreateExtension_dynamicCached_hsmCalledOnce() throws Exception { CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE", "AES", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "cachedKeyValue01"); manager.createExtension("DYN_CACHE", runtimeCtx); manager.createExtension("DYN_CACHE", runtimeCtx); verify(mockHsmCryptoService, times(1)).getSecretKey(anyString()); } @Test @DisplayName("3-3. DYNAMIC — 잘못된 전략 FQCN — IllegalArgumentException") void testCreateExtension_invalidStrategyFqcn_throwsException() throws Exception { CryptoModuleConfig config = buildDynamicConfig("DYN_BAD_FQCN", "AES", "CBC", "PKCS5Padding"); config.setKeyDerivStrategy("com.example.NonExistentStrategy"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); assertThrows(IllegalArgumentException.class, () -> manager.createExtension("DYN_BAD_FQCN", runtimeCtx), "존재하지 않는 FQCN이면 IllegalArgumentException이 발생해야 한다"); } @Test @DisplayName("3-4. DYNAMIC — 동일 FQCN 반복 호출 시 strategyCache에서 재사용") void testCreateExtension_sameFqcn_strategyCacheReused() throws Exception { CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE_STRAT", "AES", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); Map runtimeCtx = new HashMap<>(); runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01"); manager.createExtension("DYN_CACHE_STRAT", runtimeCtx); manager.createExtension("DYN_CACHE_STRAT", runtimeCtx); Field stratCacheField = CryptoModuleManager.class.getDeclaredField("strategyCache"); stratCacheField.setAccessible(true); Map stratCache = (Map) stratCacheField.get(manager); assertEquals(1, stratCache.size(), "동일 FQCN은 strategyCache에 하나만 존재해야 한다"); } @Test @DisplayName("3-5. DYNAMIC — 다른 runtimeContext 값은 다른 키 도출 (동적 키 캐시 미사용)") void testCreateExtension_dynamicDifferentContext_differentKey() throws Exception { CryptoModuleConfig config = buildDynamicConfig("DYN_DIFF", "AES", "CBC", "PKCS5Padding"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); Map ctx1 = new HashMap<>(); ctx1.put("X-Api-Enc-Key", "keyValueAAA00001"); Map ctx2 = new HashMap<>(); ctx2.put("X-Api-Enc-Key", "keyValueBBB00001"); manager.createExtension("DYN_DIFF", ctx1); manager.createExtension("DYN_DIFF", ctx2); // 컨텍스트 값이 달라 동적 키 캐시 미사용 → HSM 2회 호출 verify(mockHsmCryptoService, times(2)).getSecretKey(anyString()); } // ========================================================================= // 4. reload // ========================================================================= @Test @DisplayName("4-1. reload(cryptoId) — 변경된 설정 반영") void testReload_specificId_configUpdated() throws Exception { CryptoModuleConfig config = buildStaticConfig("RELOAD_MOD", "AES", "CBC", "PKCS5Padding"); config.setCryptoId("reload-id-001"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); CryptoModuleConfig updated = buildStaticConfig("RELOAD_MOD", "ARIA", "CBC", "PKCS5Padding"); updated.setCryptoId("reload-id-001"); when(mockLoader.findById("reload-id-001")).thenReturn(Optional.of(updated)); manager.reload("reload-id-001"); // 변경된 설정(ARIA)으로 암복호화 가능한지 확인 byte[] plain = "리로드 테스트".getBytes(StandardCharsets.UTF_8); CryptoModuleExtension ext = manager.createExtension("RELOAD_MOD"); assertNotNull(ext); } @Test @DisplayName("4-2. reload(cryptoId) use_yn=N — configMap에서 제거") void testReload_specificId_disabled_removedFromMap() throws Exception { CryptoModuleConfig config = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding"); config.setCryptoId("rm-id-001"); when(mockLoader.findAll()).thenReturn(Arrays.asList(config)); reload(); CryptoModuleConfig disabled = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding"); disabled.setCryptoId("rm-id-001"); disabled.setUseYn("N"); when(mockLoader.findById("rm-id-001")).thenReturn(Optional.of(disabled)); manager.reload("rm-id-001"); assertThrows(IllegalArgumentException.class, () -> manager.createExtension("RM_MOD"), "비활성화된 모듈은 configMap에서 제거되어야 한다"); } // ========================================================================= // 헬퍼 // ========================================================================= private void reload() throws Exception { clearField("configMap"); clearField("dynamicKeyCache"); clearField("strategyCache"); Method loadAll = CryptoModuleManager.class.getDeclaredMethod("loadAll"); loadAll.setAccessible(true); loadAll.invoke(manager); reset(mockHsmCryptoService); SecretKey masterKey = new SecretKeySpec(KEY_128, "AES"); when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey); } private void clearField(String fieldName) throws Exception { Field field = CryptoModuleManager.class.getDeclaredField(fieldName); field.setAccessible(true); ((Map) field.get(manager)).clear(); } private CryptoModuleConfig buildStaticConfig(String name, String alg, String mode, String padding) { CryptoModuleConfig c = new CryptoModuleConfig(); c.setCryptoId(java.util.UUID.randomUUID().toString()); c.setCryptoName(name); c.setAlgType(alg); c.setCipherMode(mode); c.setPadding(padding); c.setIvHex(IV_HEX); c.setKeySourceType("STATIC"); c.setEncKeyHex(ENC_KEY_HEX); c.setDecKeyHex(ENC_KEY_HEX); c.setCacheYn("Y"); c.setCacheTtlSec(300); c.setUseYn("Y"); return c; } private CryptoModuleConfig buildDynamicConfig(String name, String alg, String mode, String padding) { CryptoModuleConfig c = new CryptoModuleConfig(); c.setCryptoId(java.util.UUID.randomUUID().toString()); c.setCryptoName(name); c.setAlgType(alg); c.setCipherMode(mode); c.setPadding(padding); c.setIvHex(IV_HEX); c.setKeySourceType("DYNAMIC"); c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy"); c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}"); c.setCacheYn("Y"); c.setCacheTtlSec(300); c.setUseYn("Y"); return c; } private static String toHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) sb.append(String.format("%02x", b)); return sb.toString(); } /** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */ private static CryptoModuleConfigMapper testMapper() { return new CryptoModuleConfigMapper() { @Override public CryptoModuleConfigVO toVo(CryptoModuleConfig e) { CryptoModuleConfigVO vo = new CryptoModuleConfigVO(); vo.setCryptoId(e.getCryptoId()); vo.setCryptoName(e.getCryptoName()); vo.setCryptoDesc(e.getCryptoDesc()); vo.setAlgType(e.getAlgType()); vo.setCipherMode(e.getCipherMode()); vo.setPadding(e.getPadding()); vo.setIvHex(e.getIvHex()); vo.setKeySourceType(e.getKeySourceType()); vo.setEncKeyHex(e.getEncKeyHex()); vo.setDecKeyHex(e.getDecKeyHex()); vo.setKeyDerivStrategy(e.getKeyDerivStrategy()); vo.setKeyDerivParams(e.getKeyDerivParams()); vo.setCacheYn(e.getCacheYn()); vo.setCacheTtlSec(e.getCacheTtlSec()); vo.setUseYn(e.getUseYn()); return vo; } @Override public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) { return null; } }; } }