getRdbmsInstance(String instanceId, CloudSessionCredential session);
+
+ /**
+ * RDBMS 인스턴스의 현재 상태를 확인합니다.
+ *
+ * @param instanceId 인스턴스 ID
+ * @param session 세션 자격증명
+ * @return 인스턴스 상태
+ */
+ String getInstanceStatus(String instanceId, CloudSessionCredential session);
+}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java
new file mode 100644
index 00000000..3f760460
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsLifecyclePort.java
@@ -0,0 +1,30 @@
+package com.agenticcp.core.domain.cloud.port.outbound.rdbms;
+
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.outbound.ResourceLifecyclePort;
+
+/**
+ * RDBMS 생명주기 관리 책임 포트
+ *
+ * RDBMS 인스턴스의 시작, 중지, 재시작 기능을 제공합니다.
+ * ResourceLifecyclePort를 확장하여 일반적인 리소스 생명주기 관리 기능을 상속받고,
+ * RDBMS 특화 기능인 reboot를 추가로 제공합니다.
+ *
+ * 모든 Lifecycle 작업은 세션 자격증명을 명시적으로 전달받습니다.
+ *
+ * @author AgenticCP Team
+ * @version 2.0.0
+ */
+public interface RdbmsLifecyclePort extends ResourceLifecyclePort {
+
+ /**
+ * RDBMS 인스턴스 재시작
+ *
+ * 일반적인 리소스의 경우 stop 후 start를 순차적으로 호출하지만,
+ * RDBMS의 경우 reboot는 OS 레벨 재부팅으로 더 빠르고 안전한 재시작을 제공합니다.
+ *
+ * @param instanceId 인스턴스 ID
+ * @param session 세션 자격증명
+ */
+ void rebootInstance(String instanceId, CloudSessionCredential session);
+}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java
new file mode 100644
index 00000000..b72b58d3
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/rdbms/RdbmsManagementPort.java
@@ -0,0 +1,41 @@
+package com.agenticcp.core.domain.cloud.port.outbound.rdbms;
+
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsDeleteCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand;
+
+/**
+ * RDBMS 관리 포트 - RDBMS 인스턴스의 CRUD 작업을 정의하는 계약
+ *
+ * RDBMS 인스턴스의 생성, 수정, 삭제 기능을 제공합니다.
+ * 모든 Management 작업은 세션 자격증명을 명시적으로 전달받습니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+public interface RdbmsManagementPort {
+
+ /**
+ * RDBMS 인스턴스 생성
+ *
+ * @param command 생성 명령
+ * @return 생성된 RDBMS 인스턴스
+ */
+ CloudResource createRdbms(RdbmsCreateCommand command);
+
+ /**
+ * RDBMS 인스턴스 수정
+ *
+ * @param command 수정 명령
+ * @return 수정된 RDBMS 인스턴스
+ */
+ CloudResource updateRdbms(RdbmsUpdateCommand command);
+
+ /**
+ * RDBMS 인스턴스 삭제
+ *
+ * @param command 삭제 명령
+ */
+ void deleteRdbms(RdbmsDeleteCommand command);
+}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java
new file mode 100644
index 00000000..4e0bd3cb
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsPortRouter.java
@@ -0,0 +1,83 @@
+package com.agenticcp.core.domain.cloud.service.rdbms;
+
+import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RDBMS 포트 라우터
+ *
+ * 다양한 클라우드 제공업체의 RDBMS 관리 포트를 관리하고,
+ * 요청된 제공업체 타입에 따라 적절한 포트를 선택하여 반환합니다.
+ *
+ * 헥사고날 아키텍처의 라우터 계층에 해당하며, 제공업체별 어댑터를 동적으로 선택합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Slf4j
+@Component
+public class RdbmsPortRouter {
+
+ private final Map managementPorts;
+ private final Map discoveryPorts;
+ private final Map lifecyclePorts;
+
+ public RdbmsPortRouter(
+ List managementPortList,
+ List discoveryPortList,
+ List lifecyclePortList
+ ) {
+ this.managementPorts = buildPortMap(managementPortList);
+ this.discoveryPorts = buildPortMap(discoveryPortList);
+ this.lifecyclePorts = buildPortMap(lifecyclePortList);
+
+ log.info("[RdbmsPortRouter] RDBMS router initialized: management={}, discovery={}, lifecycle={}",
+ managementPorts.keySet(), discoveryPorts.keySet(), lifecyclePorts.keySet());
+ }
+
+ private Map buildPortMap(List ports) {
+ Map map = new EnumMap<>(ProviderType.class);
+ ports.stream()
+ .filter(port -> port instanceof ProviderScoped)
+ .forEach(port -> {
+ ProviderType providerType = ((ProviderScoped) port).getProviderType();
+ map.put(providerType, port);
+ log.debug("[RdbmsPortRouter] Registered {} port for provider {}",
+ port.getClass().getSimpleName(), providerType);
+ });
+ return map;
+ }
+
+ public RdbmsManagementPort management(ProviderType providerType) {
+ RdbmsManagementPort port = managementPorts.get(providerType);
+ if (port == null) {
+ throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType);
+ }
+ return port;
+ }
+
+ public RdbmsDiscoveryPort discovery(ProviderType providerType) {
+ RdbmsDiscoveryPort port = discoveryPorts.get(providerType);
+ if (port == null) {
+ throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType);
+ }
+ return port;
+ }
+
+ public RdbmsLifecyclePort lifecycle(ProviderType providerType) {
+ RdbmsLifecyclePort port = lifecyclePorts.get(providerType);
+ if (port == null) {
+ throw new IllegalArgumentException("생명주기 관리를 지원하지 않는 프로바이더입니다: " + providerType);
+ }
+ return port;
+ }
+}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java
new file mode 100644
index 00000000..6e43a6c2
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseService.java
@@ -0,0 +1,631 @@
+package com.agenticcp.core.domain.cloud.service.rdbms;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.crypto.EncryptionService;
+import com.agenticcp.core.common.logging.LogMaskingUtils;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsQueryRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsDeleteCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsQuery;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand;
+import com.agenticcp.core.domain.cloud.port.model.ResourceIdentity;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * RDBMS 유스케이스 서비스
+ *
+ * 헥사고날 아키텍처의 애플리케이션 계층에서 RDBMS 인스턴스 관련 비즈니스 로직을 처리합니다.
+ * 포트 인터페이스를 통해서만 외부 시스템과 통신하며, 트랜잭션을 담당합니다.
+ *
+ * JIT 세션 관리 패턴을 따릅니다:
+ * - 모든 작업에서 Service 레벨에서 세션을 획득하여 Port에 전달
+ * - getSession()을 통한 Redis 캐싱 활용
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RdbmsUseCaseService {
+
+ /**
+ * RDBMS 리소스 타입
+ */
+ private static final String RESOURCE_TYPE = "DATABASE";
+
+ private final RdbmsPortRouter portRouter;
+ private final CapabilityGuard capabilityGuard;
+ private final AccountCredentialManagementPort credentialPort;
+ private final CloudResourceManagementHelper resourceHelper;
+ private final EncryptionService encryptionService;
+
+ /**
+ * 세션 자격증명을 획득합니다.
+ * JIT 세션 관리 패턴을 따릅니다.
+ * - Redis 캐시 확인 → 없으면 발급 → 캐싱 → 반환
+ *
+ * @param providerType 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @return CloudSessionCredential 세션 자격증명
+ */
+ private CloudSessionCredential getSession(ProviderType providerType, String accountScope) {
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+ return credentialPort.getSession(tenantKey, accountScope, providerType);
+ }
+
+ // ==================== 인스턴스 조회 ====================
+
+ /**
+ * RDBMS 인스턴스 목록을 조회합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param request 조회 요청
+ * @return CloudResource 페이지
+ */
+ @Transactional(readOnly = true)
+ public Page listRdbmsInstances(ProviderType providerType, String accountScope, RdbmsQueryRequest request) {
+ log.debug("RDBMS 인스턴스 목록 조회 시작: provider={}, accountScope={}, request={}",
+ providerType, accountScope, request);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // Query 변환
+ RdbmsQuery query = toQuery(providerType, accountScope, request);
+
+ Page result = portRouter.discovery(providerType).listRdbmsInstances(query, session);
+
+ log.info("RDBMS 인스턴스 목록 조회 완료: provider={}, totalElements={}", providerType, result.getTotalElements());
+ return result;
+ }
+
+ /**
+ * 특정 RDBMS 인스턴스를 조회합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param instanceId 인스턴스 ID
+ * @return CloudResource (존재하지 않으면 Optional.empty())
+ */
+ @Transactional(readOnly = true)
+ public Optional getRdbmsInstance(ProviderType providerType, String accountScope, String instanceId) {
+ log.debug("RDBMS 인스턴스 조회 시작: provider={}, accountScope={}, instanceId={}",
+ providerType, accountScope, instanceId);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ Optional result = portRouter.discovery(providerType).getRdbmsInstance(instanceId, session);
+
+ log.info("RDBMS 인스턴스 조회 완료: provider={}, instanceId={}, found={}",
+ providerType, instanceId, result.isPresent());
+ return result;
+ }
+
+ // ==================== 인스턴스 생성 ====================
+
+ /**
+ * 새로운 RDBMS 인스턴스를 생성합니다.
+ * CSP에서 인스턴스 생성 후 CloudResource 엔티티를 DB에 저장합니다.
+ *
+ * 보상 트랜잭션: DB 저장 실패 시 CSP에 생성된 인스턴스를 삭제하여
+ * 데이터 정합성(Ghost Resource 방지)을 보장합니다.
+ *
+ * @param request 생성 요청 정보 (providerType, accountScope 포함)
+ * @return 생성된 CloudResource 엔티티
+ * @throws BusinessException DB 저장 실패 및 보상 트랜잭션 실행 시
+ */
+ @Transactional
+ public CloudResource createRdbms(RdbmsCreateRequest request) {
+ ProviderType providerType = request.getProviderType();
+ String accountScope = request.getAccountScope();
+
+ // 로깅용 마스킹된 요청 (원본 데이터는 변경하지 않음)
+ String maskedRequest = LogMaskingUtils.maskSensitiveData(request.toString());
+ log.debug("RDBMS 인스턴스 생성 시작: provider={}, accountScope={}, request={}",
+ providerType, accountScope, maskedRequest);
+
+ // Capability 검증 (CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.CREATE);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // Command 변환
+ RdbmsCreateCommand command = toCreateCommand(request, session);
+
+ // CSP에서 RDBMS 인스턴스 생성
+ CloudResource resource = portRouter.management(providerType).createRdbms(command);
+
+ // DB에 CloudResource 저장 (실패 시 보상 트랜잭션 실행)
+ CloudResource savedResource;
+ try {
+ // ResourceRegistrationRequest 생성
+ Map attributes = new HashMap<>();
+ attributes.put(ResourceRegistrationRequest.AttributeKeys.INSTANCE_SIZE, request.getInstanceSize());
+ attributes.put(ResourceRegistrationRequest.AttributeKeys.STORAGE_GB, request.getAllocatedStorage());
+ if (request.getEngine() != null) {
+ attributes.put("engine", request.getEngine());
+ }
+ if (request.getEngineVersion() != null) {
+ attributes.put("engineVersion", request.getEngineVersion());
+ }
+
+ ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder()
+ .resourceId(resource.getResourceId())
+ .resourceName(resource.getResourceName() != null ? resource.getResourceName() : request.getInstanceName())
+ .resourceType(CloudResource.ResourceType.DATABASE)
+ .tags(request.getTags() != null ? request.getTags() : resource.getTags())
+ .attributes(attributes)
+ .build();
+
+ // DB에 CloudResource 저장
+ savedResource = resourceHelper.registerResource(
+ providerType,
+ serviceKey,
+ registrationRequest
+ );
+
+ log.info("RDBMS 인스턴스 생성 완료: provider={}, instanceId={}, resourceId={}",
+ providerType, resource.getResourceId(), savedResource.getId());
+ } catch (Exception e) {
+ log.error("[RdbmsUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: instanceId={}, error={}",
+ resource.getResourceId(), e.getMessage());
+
+ // 보상 트랜잭션: CSP에 생성된 인스턴스 삭제
+ executeCompensatingTransaction(providerType, accountScope, session, resource.getResourceId());
+
+ throw new BusinessException(
+ CloudErrorCode.RESOURCE_CREATION_FAILED,
+ "RDBMS 인스턴스 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + resource.getResourceId()
+ );
+ }
+
+ return savedResource;
+ }
+
+ /**
+ * 보상 트랜잭션: CSP에 생성된 RDBMS 인스턴스를 삭제합니다.
+ * Ghost Resource 방지를 위해 DB 저장 실패 시 호출됩니다.
+ *
+ * @param providerType 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param session 세션 자격증명
+ * @param instanceId 삭제할 인스턴스 ID
+ */
+ private void executeCompensatingTransaction(
+ ProviderType providerType,
+ String accountScope,
+ CloudSessionCredential session,
+ String instanceId
+ ) {
+ try {
+ log.warn("[RdbmsUseCaseService] 보상 트랜잭션 실행: CSP 인스턴스 삭제 시도 - instanceId={}", instanceId);
+
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ RdbmsDeleteCommand deleteCommand = RdbmsDeleteCommand.builder()
+ .providerType(providerType)
+ .accountScope(accountScope)
+ .region(null) // region은 Adapter에서 처리
+ .providerResourceId(instanceId)
+ .skipSnapshot(true) // 보상 트랜잭션이므로 스냅샷 생성하지 않음
+ .deleteAutomatedBackups(false)
+ .tenantKey(tenantKey)
+ .session(session)
+ .build();
+
+ portRouter.management(providerType).deleteRdbms(deleteCommand);
+ log.info("[RdbmsUseCaseService] 보상 트랜잭션 완료: CSP 인스턴스 삭제 성공 - instanceId={}", instanceId);
+ } catch (Exception compensationError) {
+ // 보상 트랜잭션도 실패한 경우 - Ghost Resource 발생
+ // 이 경우 별도의 모니터링/알림 시스템이나 배치 동기화로 처리 필요
+ log.error("[RdbmsUseCaseService] 보상 트랜잭션 실패: Ghost Resource 발생 가능 - instanceId={}, error={}",
+ instanceId, compensationError.getMessage());
+ }
+ }
+
+ // ==================== 인스턴스 수정 ====================
+
+ /**
+ * RDBMS 인스턴스 정보를 수정합니다.
+ *
+ * @param request 수정 요청 정보 (providerType, accountScope 포함)
+ * @return 수정된 CloudResource 엔티티
+ */
+ @Transactional
+ public CloudResource updateRdbms(RdbmsUpdateRequest request) {
+ ProviderType providerType = request.getProviderType();
+ String accountScope = request.getAccountScope();
+
+ // 로깅용 마스킹된 요청 (원본 데이터는 변경하지 않음)
+ String maskedRequest = LogMaskingUtils.maskSensitiveData(request.toString());
+ log.debug("RDBMS 인스턴스 수정: provider={}, accountScope={}, request={}",
+ providerType, accountScope, maskedRequest);
+
+ // Capability 검증 (CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.UPDATE);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // Command 변환
+ RdbmsUpdateCommand command = toUpdateCommand(request, session);
+
+ // RDBMS 인스턴스 수정
+ CloudResource resource = portRouter.management(providerType).updateRdbms(command);
+
+ // DB에 수정된 정보 반영 (instanceSize, allocatedStorage 등)
+ try {
+ // 수정된 리소스 정보를 DB에 동기화
+ // Adapter에서 반환된 CloudResource의 정보를 DB에 업데이트
+ if (request.getInstanceSize() != null || request.getAllocatedStorage() != null) {
+ // DB에서 기존 리소스 조회 후 업데이트하거나,
+ // Adapter에서 반환된 resource의 정보를 기반으로 DB 업데이트
+ // 현재는 Adapter가 이미 CloudResource를 반환하므로,
+ // 생명주기 상태만 업데이트 (실제 속성 업데이트는 별도 동기화 작업에서 처리)
+ resourceHelper.updateLifecycleState(
+ request.getInstanceId(),
+ resource.getLifecycleState() != null ? resource.getLifecycleState() : LifecycleState.RUNNING
+ );
+ }
+ } catch (Exception e) {
+ log.warn("[RdbmsUseCaseService] DB 업데이트 실패 (수정은 완료됨): instanceId={}, error={}",
+ request.getInstanceId(), e.getMessage());
+ // DB 업데이트 실패해도 CSP 수정은 완료되었으므로 예외를 던지지 않음
+ }
+
+ log.info("RDBMS 인스턴스 수정 완료: provider={}, instanceId={}", providerType, request.getInstanceId());
+ return resource;
+ }
+
+ // ==================== 인스턴스 삭제 ====================
+
+ /**
+ * RDBMS 인스턴스를 삭제합니다.
+ * CSP에서 인스턴스 삭제 후 DB에서 소프트 삭제 처리합니다.
+ *
+ * @param request 삭제 요청 정보 (providerType, accountScope 포함)
+ */
+ @Transactional
+ public void deleteRdbms(RdbmsDeleteRequest request) {
+ ProviderType providerType = request.getProviderType();
+ String accountScope = request.getAccountScope();
+ String instanceId = request.getInstanceId();
+
+ log.debug("RDBMS 인스턴스 삭제: provider={}, accountScope={}, request={}",
+ providerType, accountScope, request);
+
+ // Capability 검증 (CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.TERMINATE);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // Command 변환
+ RdbmsDeleteCommand command = toDeleteCommand(request, session);
+
+ // CSP에서 RDBMS 인스턴스 삭제
+ portRouter.management(providerType).deleteRdbms(command);
+
+ // DB 소프트 삭제
+ resourceHelper.softDeleteResource(instanceId);
+
+ log.info("RDBMS 인스턴스 삭제 완료: provider={}, instanceId={}", providerType, instanceId);
+ }
+
+ // ==================== 인스턴스 생명주기 관리 ====================
+
+ /**
+ * RDBMS 인스턴스를 시작합니다.
+ * CSP에서 인스턴스 시작 후 DB의 lifecycleState를 RUNNING으로 업데이트합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param instanceId 인스턴스 ID
+ */
+ @Transactional
+ public void startInstance(ProviderType providerType, String accountScope, String instanceId) {
+ log.debug("RDBMS 인스턴스 시작: provider={}, accountScope={}, instanceId={}",
+ providerType, accountScope, instanceId);
+
+ // Capability 검증 (CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.START);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // ResourceIdentity 생성 (region 정보는 인스턴스 조회를 통해 얻거나 null로 설정)
+ String region = getRegionFromInstance(providerType, accountScope, instanceId, session);
+ ResourceIdentity resourceId = ResourceIdentity.builder()
+ .providerType(providerType)
+ .accountScope(accountScope)
+ .region(region)
+ .providerResourceId(instanceId)
+ .serviceKey(serviceKey)
+ .resourceType(RESOURCE_TYPE)
+ .build();
+
+ // CSP에서 RDBMS 인스턴스 시작 (RdbmsLifecyclePort는 ResourceLifecyclePort를 확장)
+ portRouter.lifecycle(providerType).start(resourceId, session);
+
+ // DB 상태 업데이트: RUNNING
+ resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING);
+
+ log.info("RDBMS 인스턴스 시작 완료: provider={}, instanceId={}", providerType, instanceId);
+ }
+
+ /**
+ * RDBMS 인스턴스를 중지합니다.
+ * CSP에서 인스턴스 중지 후 DB의 lifecycleState를 STOPPED로 업데이트합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param instanceId 인스턴스 ID
+ */
+ @Transactional
+ public void stopInstance(ProviderType providerType, String accountScope, String instanceId) {
+ log.debug("RDBMS 인스턴스 중지: provider={}, accountScope={}, instanceId={}",
+ providerType, accountScope, instanceId);
+
+ // Capability 검증 (CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.STOP);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // ResourceIdentity 생성 (region 정보는 인스턴스 조회를 통해 얻거나 null로 설정)
+ String region = getRegionFromInstance(providerType, accountScope, instanceId, session);
+ ResourceIdentity resourceId = ResourceIdentity.builder()
+ .providerType(providerType)
+ .accountScope(accountScope)
+ .region(region)
+ .providerResourceId(instanceId)
+ .serviceKey(serviceKey)
+ .resourceType(RESOURCE_TYPE)
+ .build();
+
+ // CSP에서 RDBMS 인스턴스 중지 (RdbmsLifecyclePort는 ResourceLifecyclePort를 확장)
+ portRouter.lifecycle(providerType).stop(resourceId, session);
+
+ // DB 상태 업데이트: STOPPED
+ resourceHelper.updateLifecycleState(instanceId, LifecycleState.STOPPED);
+
+ log.info("RDBMS 인스턴스 중지 완료: provider={}, instanceId={}", providerType, instanceId);
+ }
+
+ /**
+ * RDBMS 인스턴스를 재시작합니다.
+ * CSP에서 인스턴스 재시작 후 DB의 lifecycleState를 RUNNING으로 유지합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param instanceId 인스턴스 ID
+ */
+ @Transactional
+ public void rebootInstance(ProviderType providerType, String accountScope, String instanceId) {
+ log.debug("RDBMS 인스턴스 재시작: provider={}, accountScope={}, instanceId={}",
+ providerType, accountScope, instanceId);
+
+ // Capability 검증 (START와 STOP이 모두 필요, CSP별 실제 서비스 키 사용)
+ String serviceKey = getServiceKeyForProvider(providerType);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.STOP);
+ capabilityGuard.ensureSupported(providerType, serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.START);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ // CSP에서 RDBMS 인스턴스 재시작
+ portRouter.lifecycle(providerType).rebootInstance(instanceId, session);
+
+ // DB 상태 업데이트: 재시작 후 RUNNING 상태 유지
+ resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING);
+
+ log.info("RDBMS 인스턴스 재시작 완료: provider={}, instanceId={}", providerType, instanceId);
+ }
+
+ // ==================== 상태 확인 ====================
+
+ /**
+ * RDBMS 인스턴스의 현재 상태를 확인합니다.
+ *
+ * @param providerType 클라우드 프로바이더 타입
+ * @param accountScope 계정 스코프
+ * @param instanceId 인스턴스 ID
+ * @return 인스턴스 상태
+ */
+ @Transactional(readOnly = true)
+ public String getInstanceStatus(ProviderType providerType, String accountScope, String instanceId) {
+ log.debug("RDBMS 인스턴스 상태 확인: provider={}, accountScope={}, instanceId={}",
+ providerType, accountScope, instanceId);
+
+ // 세션 획득
+ CloudSessionCredential session = getSession(providerType, accountScope);
+
+ String status = portRouter.discovery(providerType).getInstanceStatus(instanceId, session);
+
+ log.info("RDBMS 인스턴스 상태 확인 완료: provider={}, instanceId={}, status={}",
+ providerType, instanceId, status);
+ return status;
+ }
+
+ // ==================== Helper Methods ====================
+
+ /**
+ * 인스턴스 ID로부터 region 정보를 조회합니다.
+ * 인스턴스가 존재하지 않으면 null을 반환합니다.
+ */
+ private String getRegionFromInstance(ProviderType providerType, String accountScope,
+ String instanceId, CloudSessionCredential session) {
+ try {
+ Optional resource = portRouter.discovery(providerType)
+ .getRdbmsInstance(instanceId, session);
+ return resource
+ .map(r -> r.getRegion() != null ? r.getRegion().getRegionKey() : null)
+ .orElse(null);
+ } catch (Exception e) {
+ log.warn("Failed to get region for instance {}: {}", instanceId, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 프로바이더 타입에 따른 서비스 키 반환
+ * AWS: RDS, Azure: AzureDatabase, GCP: CloudSQL 등
+ */
+ private String getServiceKeyForProvider(ProviderType providerType) {
+ return switch (providerType) {
+ case AWS -> "RDS";
+ case AZURE -> "AzureDatabase";
+ case GCP -> "CloudSQL";
+ default -> "RDBMS";
+ };
+ }
+
+ // ==================== Command 변환 ====================
+
+ private RdbmsQuery toQuery(ProviderType providerType, String accountScope, RdbmsQueryRequest request) {
+ return RdbmsQuery.builder()
+ .providerType(providerType)
+ .accountScope(accountScope)
+ .regions(request.getRegions())
+ .instanceName(request.getInstanceName())
+ .engine(request.getEngine())
+ .instanceSize(request.getInstanceSize())
+ .status(request.getStatus())
+ .tagsEquals(request.getTags())
+ .page(request.getPage())
+ .size(request.getSize())
+ .build();
+ }
+
+ private RdbmsCreateCommand toCreateCommand(RdbmsCreateRequest request, CloudSessionCredential session) {
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+ String serviceKey = getServiceKeyForProvider(request.getProviderType());
+
+ // adminPassword 암호화
+ String encryptedPassword;
+ try {
+ encryptedPassword = encryptionService.encrypt(request.getMasterPassword());
+ log.debug("RDBMS adminPassword 암호화 완료");
+ } catch (Exception e) {
+ log.error("RDBMS adminPassword 암호화 실패", e);
+ throw new BusinessException(
+ CloudErrorCode.ENCRYPTION_FAILED,
+ "관리자 패스워드 암호화에 실패했습니다: " + e.getMessage()
+ );
+ }
+
+ return RdbmsCreateCommand.builder()
+ .providerType(request.getProviderType())
+ .accountScope(request.getAccountScope())
+ .region(request.getRegion())
+ .serviceKey(serviceKey)
+ .resourceType(RESOURCE_TYPE)
+ .instanceName(request.getInstanceName())
+ .engine(request.getEngine())
+ .engineVersion(request.getEngineVersion())
+ .instanceSize(request.getInstanceSize())
+ .allocatedStorage(request.getAllocatedStorage())
+ .adminUsername(request.getMasterUsername())
+ .adminPassword(encryptedPassword) // 암호화된 패스워드 전달
+ .dbName(request.getDbName())
+ .networkSecurityId(request.getNetworkSecurityId())
+ .subnetId(request.getSubnetId())
+ .port(request.getPort())
+ .zone(request.getZone())
+ .highAvailability(request.getHighAvailability())
+ .publiclyAccessible(request.getPubliclyAccessible())
+ .tags(request.getTags())
+ .tenantKey(tenantKey)
+ .providerSpecificConfig(request.getProviderSpecificConfig())
+ .session(session)
+ .build();
+ }
+
+ private RdbmsUpdateCommand toUpdateCommand(RdbmsUpdateRequest request, CloudSessionCredential session) {
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ // tagsToAdd를 tags로 사용 (tagsToRemove는 Adapter에서 처리하거나 별도 필드로 전달 필요)
+ // 현재는 tagsToAdd만 전달 (실제 구현에서는 Adapter에서 태그 추가/제거를 별도로 처리)
+ java.util.Map tags = request.getTagsToAdd() != null
+ ? new java.util.HashMap<>(request.getTagsToAdd())
+ : new java.util.HashMap<>();
+
+ // adminPassword 암호화 (수정 시 선택적 - 패스워드 변경하지 않을 수도 있음)
+ String encryptedPassword = null;
+ if (request.getMasterPassword() != null) {
+ try {
+ encryptedPassword = encryptionService.encrypt(request.getMasterPassword());
+ log.debug("RDBMS adminPassword 암호화 완료 (수정)");
+ } catch (Exception e) {
+ log.error("RDBMS adminPassword 암호화 실패 (수정)", e);
+ throw new BusinessException(
+ CloudErrorCode.ENCRYPTION_FAILED,
+ "관리자 패스워드 암호화에 실패했습니다: " + e.getMessage()
+ );
+ }
+ }
+
+ return RdbmsUpdateCommand.builder()
+ .providerType(request.getProviderType())
+ .accountScope(request.getAccountScope())
+ .region(null) // region은 Adapter에서 처리
+ .providerResourceId(request.getInstanceId())
+ .instanceSize(request.getInstanceSize())
+ .allocatedStorage(request.getAllocatedStorage())
+ .adminPassword(encryptedPassword) // 암호화된 패스워드 전달 (null 가능)
+ .applyImmediately(request.getApplyImmediately())
+ .tags(tags)
+ .tenantKey(tenantKey)
+ .providerSpecificConfig(request.getProviderSpecificConfig())
+ .session(session)
+ .build();
+ }
+
+ private RdbmsDeleteCommand toDeleteCommand(RdbmsDeleteRequest request, CloudSessionCredential session) {
+ String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow();
+
+ return RdbmsDeleteCommand.builder()
+ .providerType(request.getProviderType())
+ .accountScope(request.getAccountScope())
+ .region(null) // region은 Adapter에서 처리
+ .providerResourceId(request.getInstanceId())
+ .skipSnapshot(request.getSkipSnapshot())
+ .snapshotName(request.getSnapshotName())
+ .deleteAutomatedBackups(request.getDeleteAutomatedBackups())
+ .tenantKey(tenantKey)
+ .providerSpecificConfig(request.getProviderSpecificConfig())
+ .session(session)
+ .build();
+ }
+
+}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java
new file mode 100644
index 00000000..e376af55
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/rds/AwsRdsManagementAdapterDecryptionTest.java
@@ -0,0 +1,509 @@
+package com.agenticcp.core.domain.cloud.adapter.outbound.aws.rds;
+
+import com.agenticcp.core.common.crypto.AesGcmEncryptionService;
+import com.agenticcp.core.common.crypto.EncryptionService;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsRdsConfig;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand;
+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.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import software.amazon.awssdk.services.rds.RdsClient;
+import software.amazon.awssdk.services.rds.model.*;
+
+import java.security.SecureRandom;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+/**
+ * AwsRdsManagementAdapter 복호화 로직 단위 테스트
+ *
+ * adminPassword 복호화 로직을 검증합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("AwsRdsManagementAdapter 복호화 테스트")
+class AwsRdsManagementAdapterDecryptionTest {
+
+ @Mock
+ private AwsRdsConfig awsRdsConfig;
+
+ @Mock
+ private AwsRdsMapper mapper;
+
+ @Mock
+ private RdsClient rdsClient;
+
+ @Mock
+ private CloudSessionCredential session;
+
+ private EncryptionService encryptionService;
+ private AwsRdsManagementAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ // 실제 EncryptionService 사용 (AES-GCM-256)
+ byte[] key = generateKey(32);
+ encryptionService = new AesGcmEncryptionService(key);
+
+ // AwsRdsManagementAdapter 인스턴스 생성 (EncryptionService 주입)
+ adapter = new AwsRdsManagementAdapter(awsRdsConfig, mapper, encryptionService);
+
+ // Mock 설정 - lenient()를 사용하여 각 테스트에서 다른 인자로 호출될 수 있음
+ lenient().when(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString()))
+ .thenReturn(rdsClient);
+ }
+
+ /**
+ * AES 키 생성 헬퍼 메서드
+ */
+ private byte[] generateKey(int size) {
+ byte[] key = new byte[size];
+ new SecureRandom().nextBytes(key);
+ return key;
+ }
+
+ @Nested
+ @DisplayName("adminPassword 복호화 테스트 - 생성 시")
+ class CreateRdbmsDecryptionTest {
+
+ @Test
+ @DisplayName("암호화된 adminPassword가 정상적으로 복호화되어 AWS SDK 요청에 전달됨")
+ void shouldDecryptPasswordWhenCreatingRdbms() throws Exception {
+ // given
+ String plainPassword = "MySecurePassword123!";
+ String encryptedPassword = encryptionService.encrypt(plainPassword);
+
+ RdbmsCreateCommand command = RdbmsCreateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .serviceKey("RDS")
+ .resourceType("DATABASE")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .adminUsername("admin")
+ .adminPassword(encryptedPassword) // 암호화된 패스워드
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("test-db")
+ .dbInstanceStatus("available")
+ .build();
+
+ CreateDbInstanceResponse response = CreateDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(CreateDbInstanceRequest.class);
+
+ // when
+ adapter.createRdbms(command);
+
+ // then
+ verify(rdsClient).createDBInstance(requestCaptor.capture());
+ CreateDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ // AWS SDK 요청에 전달된 패스워드가 복호화된 평문인지 확인
+ String requestPassword = capturedRequest.masterUserPassword();
+ assertThat(requestPassword).isNotNull();
+ assertThat(requestPassword).isEqualTo(plainPassword);
+ assertThat(requestPassword).isNotEqualTo(encryptedPassword);
+ }
+
+ @Test
+ @DisplayName("복호화 실패 시 DECRYPTION_FAILED 예외 발생")
+ void shouldThrowExceptionWhenDecryptionFails() {
+ // given
+ EncryptionService failingEncryptionService = mock(EncryptionService.class);
+ AwsRdsManagementAdapter adapterWithFailingDecryption =
+ new AwsRdsManagementAdapter(awsRdsConfig, mapper, failingEncryptionService);
+
+ String invalidEncryptedPassword = "invalid-encrypted-password";
+ RdbmsCreateCommand command = RdbmsCreateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .serviceKey("RDS")
+ .resourceType("DATABASE")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .adminUsername("admin")
+ .adminPassword(invalidEncryptedPassword)
+ .session(session) // session 추가
+ .build();
+
+ given(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString()))
+ .willReturn(rdsClient);
+ given(failingEncryptionService.decrypt(anyString()))
+ .willThrow(new RuntimeException("복호화 실패"));
+
+ // when & then
+ assertThatThrownBy(() -> adapterWithFailingDecryption.createRdbms(command))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.DECRYPTION_FAILED);
+ assertThat(be.getMessage()).contains("관리자 패스워드 복호화에 실패했습니다");
+ });
+ }
+
+ @Test
+ @DisplayName("adminPassword가 null이면 AWS SDK 요청에도 null이 전달됨")
+ void shouldPassNullWhenPasswordIsNull() throws Exception {
+ // given
+ RdbmsCreateCommand command = RdbmsCreateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .serviceKey("RDS")
+ .resourceType("DATABASE")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .adminUsername("admin")
+ .adminPassword(null) // null 패스워드
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("test-db")
+ .dbInstanceStatus("available")
+ .build();
+
+ CreateDbInstanceResponse response = CreateDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(CreateDbInstanceRequest.class);
+
+ // when
+ adapter.createRdbms(command);
+
+ // then
+ verify(rdsClient).createDBInstance(requestCaptor.capture());
+ CreateDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ // AWS SDK 요청에 전달된 패스워드가 null인지 확인
+ String requestPassword = capturedRequest.masterUserPassword();
+ assertThat(requestPassword).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("adminPassword 복호화 테스트 - 수정 시")
+ class UpdateRdbmsDecryptionTest {
+
+ @Test
+ @DisplayName("암호화된 adminPassword가 정상적으로 복호화되어 AWS SDK 요청에 전달됨")
+ void shouldDecryptPasswordWhenUpdatingRdbms() throws Exception {
+ // given
+ String plainPassword = "NewSecurePassword456!";
+ String encryptedPassword = encryptionService.encrypt(plainPassword);
+
+ RdbmsUpdateCommand command = RdbmsUpdateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .providerResourceId("db-instance-123")
+ .adminPassword(encryptedPassword) // 암호화된 패스워드
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("db-instance-123")
+ .dbInstanceStatus("available")
+ .build();
+
+ ModifyDbInstanceResponse response = ModifyDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.modifyDBInstance(any(ModifyDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(ModifyDbInstanceRequest.class);
+
+ // when
+ adapter.updateRdbms(command);
+
+ // then
+ verify(rdsClient).modifyDBInstance(requestCaptor.capture());
+ ModifyDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ // AWS SDK 요청에 전달된 패스워드가 복호화된 평문인지 확인
+ String requestPassword = capturedRequest.masterUserPassword();
+ assertThat(requestPassword).isNotNull();
+ assertThat(requestPassword).isEqualTo(plainPassword);
+ assertThat(requestPassword).isNotEqualTo(encryptedPassword);
+ }
+
+ @Test
+ @DisplayName("복호화 실패 시 DECRYPTION_FAILED 예외 발생")
+ void shouldThrowExceptionWhenDecryptionFails() {
+ // given
+ EncryptionService failingEncryptionService = mock(EncryptionService.class);
+ AwsRdsManagementAdapter adapterWithFailingDecryption =
+ new AwsRdsManagementAdapter(awsRdsConfig, mapper, failingEncryptionService);
+
+ String invalidEncryptedPassword = "invalid-encrypted-password";
+ RdbmsUpdateCommand command = RdbmsUpdateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .providerResourceId("db-instance-123")
+ .adminPassword(invalidEncryptedPassword)
+ .session(session) // session 추가
+ .build();
+
+ given(awsRdsConfig.createRdsClient(any(CloudSessionCredential.class), anyString()))
+ .willReturn(rdsClient);
+ given(failingEncryptionService.decrypt(anyString()))
+ .willThrow(new RuntimeException("복호화 실패"));
+
+ // when & then
+ assertThatThrownBy(() -> adapterWithFailingDecryption.updateRdbms(command))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.DECRYPTION_FAILED);
+ assertThat(be.getMessage()).contains("관리자 패스워드 복호화에 실패했습니다");
+ });
+ }
+
+ @Test
+ @DisplayName("adminPassword가 null이면 AWS SDK 요청에도 null이 전달됨")
+ void shouldPassNullWhenPasswordIsNull() throws Exception {
+ // given
+ RdbmsUpdateCommand command = RdbmsUpdateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .providerResourceId("db-instance-123")
+ .instanceSize("db.t3.small")
+ .adminPassword(null) // null 패스워드
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("db-instance-123")
+ .dbInstanceStatus("available")
+ .build();
+
+ ModifyDbInstanceResponse response = ModifyDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.modifyDBInstance(any(ModifyDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(ModifyDbInstanceRequest.class);
+
+ // when
+ adapter.updateRdbms(command);
+
+ // then
+ verify(rdsClient).modifyDBInstance(requestCaptor.capture());
+ ModifyDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ // AWS SDK 요청에 전달된 패스워드가 null인지 확인
+ String requestPassword = capturedRequest.masterUserPassword();
+ assertThat(requestPassword).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("복호화 라운드트립 테스트")
+ class DecryptionRoundTripTest {
+
+ @Test
+ @DisplayName("암호화된 패스워드를 복호화하면 원본과 일치함")
+ void shouldDecryptToOriginalPassword() throws Exception {
+ // given
+ String plainPassword = "TestPassword123!@#";
+ String encryptedPassword = encryptionService.encrypt(plainPassword);
+
+ RdbmsCreateCommand command = RdbmsCreateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .serviceKey("RDS")
+ .resourceType("DATABASE")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .adminUsername("admin")
+ .adminPassword(encryptedPassword)
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("test-db")
+ .dbInstanceStatus("available")
+ .build();
+
+ CreateDbInstanceResponse response = CreateDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(CreateDbInstanceRequest.class);
+
+ // when
+ adapter.createRdbms(command);
+
+ // then
+ verify(rdsClient).createDBInstance(requestCaptor.capture());
+ CreateDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ String decryptedPassword = capturedRequest.masterUserPassword();
+ assertThat(decryptedPassword).isEqualTo(plainPassword);
+ }
+
+ @Test
+ @DisplayName("다양한 특수문자를 포함한 패스워드도 정상 복호화됨")
+ void shouldHandleSpecialCharacters() throws Exception {
+ // given
+ String[] testPasswords = {
+ "Password123!@#$%^&*()",
+ "한글패스워드123!",
+ "P@ssw0rd with spaces",
+ "VeryLongPassword1234567890!@#$%^&*()_+-=[]{}|;:,.<>?",
+ "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+ };
+
+ for (String plainPassword : testPasswords) {
+ String encryptedPassword = encryptionService.encrypt(plainPassword);
+
+ RdbmsCreateCommand command = RdbmsCreateCommand.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .serviceKey("RDS")
+ .resourceType("DATABASE")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .adminUsername("admin")
+ .adminPassword(encryptedPassword)
+ .session(session) // session 추가
+ .build();
+
+ // AWS SDK 응답 Mock
+ DBInstance dbInstance = DBInstance.builder()
+ .dbInstanceIdentifier("test-db")
+ .dbInstanceStatus("available")
+ .build();
+
+ CreateDbInstanceResponse response = CreateDbInstanceResponse.builder()
+ .dbInstance(dbInstance)
+ .build();
+
+ DescribeDbInstancesResponse describeResponse = DescribeDbInstancesResponse.builder()
+ .dbInstances(dbInstance)
+ .build();
+
+ given(rdsClient.createDBInstance(any(CreateDbInstanceRequest.class)))
+ .willReturn(response);
+ given(rdsClient.describeDBInstances(any(DescribeDbInstancesRequest.class)))
+ .willReturn(describeResponse);
+ given(mapper.toCloudResource(any(DBInstance.class), any(), any(), any()))
+ .willReturn(mock(com.agenticcp.core.domain.cloud.entity.CloudResource.class));
+
+ ArgumentCaptor requestCaptor =
+ ArgumentCaptor.forClass(CreateDbInstanceRequest.class);
+
+ // when
+ adapter.createRdbms(command);
+
+ // then
+ verify(rdsClient, atLeastOnce()).createDBInstance(requestCaptor.capture());
+ CreateDbInstanceRequest capturedRequest = requestCaptor.getValue();
+
+ String decryptedPassword = capturedRequest.masterUserPassword();
+ assertThat(decryptedPassword).isEqualTo(plainPassword);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java
new file mode 100644
index 00000000..d8f7c4db
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceDbSyncTest.java
@@ -0,0 +1,408 @@
+package com.agenticcp.core.domain.cloud.service.rdbms;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.crypto.EncryptionService;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest;
+import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import org.junit.jupiter.api.AfterEach;
+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.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * RdbmsUseCaseService DB 동기화 로직 단위 테스트
+ * CSP 작업 후 CloudResource 엔티티가 올바르게 DB에 저장/삭제되는지 검증합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RdbmsUseCaseService DB 동기화 테스트")
+class RdbmsUseCaseServiceDbSyncTest {
+
+ @Mock
+ private RdbmsPortRouter portRouter;
+
+ @Mock
+ private RdbmsManagementPort managementPort;
+
+ @Mock
+ private RdbmsDiscoveryPort discoveryPort;
+
+ @Mock
+ private RdbmsLifecyclePort lifecyclePort;
+
+ @Mock
+ private CapabilityGuard capabilityGuard;
+
+ @Mock
+ private AccountCredentialManagementPort credentialPort;
+
+ @Mock
+ private CloudResourceManagementHelper resourceHelper;
+
+ @Mock
+ private EncryptionService encryptionService;
+
+ private RdbmsUseCaseService rdbmsUseCaseService;
+ private CloudSessionCredential mockSession;
+
+ private static final ProviderType PROVIDER_TYPE = ProviderType.AWS;
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String TENANT_KEY = "tenant-test";
+ private static final String INSTANCE_ID = "test-instance-123";
+ private static final String REGION = "us-east-1";
+
+ @BeforeEach
+ void setUp() {
+ TenantContextHolder.setTenantKey(TENANT_KEY);
+
+ rdbmsUseCaseService = new RdbmsUseCaseService(
+ portRouter,
+ capabilityGuard,
+ credentialPort,
+ resourceHelper,
+ encryptionService
+ );
+
+ mockSession = mock(CloudSessionCredential.class);
+
+ // 공통 Mock 설정
+ lenient().when(portRouter.management(PROVIDER_TYPE)).thenReturn(managementPort);
+ lenient().when(portRouter.discovery(PROVIDER_TYPE)).thenReturn(discoveryPort);
+ lenient().when(portRouter.lifecycle(PROVIDER_TYPE)).thenReturn(lifecyclePort);
+ lenient().when(credentialPort.getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(PROVIDER_TYPE)))
+ .thenReturn(mockSession);
+ lenient().doNothing().when(capabilityGuard).ensureSupported(any(), anyString(), anyString(), any());
+ lenient().when(encryptionService.encrypt(anyString())).thenReturn("encrypted-password");
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Nested
+ @DisplayName("인스턴스 생성 테스트")
+ class CreateRdbmsTest {
+
+ @Test
+ @DisplayName("인스턴스 생성 성공 시 CloudResource가 DB에 저장된다")
+ void createRdbms_Success_SavesCloudResource() {
+ // Given
+ Map tags = Map.of("Environment", "test");
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .region(REGION)
+ .instanceName("test-instance")
+ .engine("mysql")
+ .engineVersion("8.0")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword("password123")
+ .tags(tags)
+ .build();
+
+ CloudResource mockCreatedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+
+ when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance);
+ when(resourceHelper.registerResource(any(), any(), any())).thenReturn(mockCreatedInstance);
+
+ // When
+ CloudResource result = rdbmsUseCaseService.createRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+
+ verify(resourceHelper).registerResource(
+ eq(PROVIDER_TYPE),
+ eq("RDS"),
+ any(ResourceRegistrationRequest.class)
+ );
+ }
+
+ @Test
+ @DisplayName("DB 저장 실패 시 보상 트랜잭션이 실행되고 예외가 발생한다")
+ void createRdbms_DbSaveFails_CompensatingTransactionExecuted() {
+ // Given
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .region(REGION)
+ .instanceName("test-instance")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword("password123")
+ .build();
+
+ CloudResource mockCreatedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+
+ when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance);
+ // DB 저장 실패
+ doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper)
+ .registerResource(any(), any(), any());
+ doNothing().when(managementPort).deleteRdbms(any());
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.RESOURCE_CREATION_FAILED);
+ });
+
+ // 보상 트랜잭션 실행 검증: CSP 인스턴스 삭제 호출됨
+ verify(managementPort).deleteRdbms(any());
+ }
+
+ @Test
+ @DisplayName("보상 트랜잭션도 실패하면 Ghost Resource 경고 로그가 출력된다")
+ void createRdbms_CompensationFails_GhostResourceWarningLogged() {
+ // Given
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .region(REGION)
+ .instanceName("test-instance")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword("password123")
+ .build();
+
+ CloudResource mockCreatedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+
+ when(managementPort.createRdbms(any())).thenReturn(mockCreatedInstance);
+ // DB 저장 실패
+ doThrow(new RuntimeException("DB 저장 실패")).when(resourceHelper)
+ .registerResource(any(), any(), any());
+ // 보상 트랜잭션(CSP 삭제)도 실패
+ doThrow(new RuntimeException("CSP 삭제 실패")).when(managementPort)
+ .deleteRdbms(any());
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request))
+ .isInstanceOf(BusinessException.class);
+
+ // 보상 트랜잭션 시도 검증
+ verify(managementPort).deleteRdbms(any());
+ // Ghost Resource 발생 - 실제로는 모니터링/배치로 처리 필요
+ }
+ }
+
+ @Nested
+ @DisplayName("인스턴스 삭제 테스트")
+ class DeleteRdbmsTest {
+
+ @Test
+ @DisplayName("인스턴스 삭제 시 소프트 삭제가 수행된다")
+ void deleteRdbms_Success_SoftDeletesResource() {
+ // Given
+ RdbmsDeleteRequest request = RdbmsDeleteRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .skipSnapshot(false)
+ .build();
+
+ doNothing().when(managementPort).deleteRdbms(any());
+
+ // When
+ rdbmsUseCaseService.deleteRdbms(request);
+
+ // Then
+ verify(managementPort).deleteRdbms(any());
+ verify(resourceHelper).softDeleteResource(INSTANCE_ID);
+ }
+
+ @Test
+ @DisplayName("DB에 리소스가 없어도 CSP 삭제는 성공한다")
+ void deleteRdbms_ResourceNotInDb_CspDeletionSucceeds() {
+ // Given
+ RdbmsDeleteRequest request = RdbmsDeleteRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .skipSnapshot(true)
+ .build();
+
+ doNothing().when(managementPort).deleteRdbms(any());
+ // Helper 내부에서 리소스가 없으면 로그만 출력하고 예외 발생 안함
+
+ // When
+ rdbmsUseCaseService.deleteRdbms(request);
+
+ // Then
+ verify(managementPort).deleteRdbms(any()); // CSP 작업 성공
+ verify(resourceHelper).softDeleteResource(INSTANCE_ID);
+ }
+ }
+
+ @Nested
+ @DisplayName("인스턴스 수정 테스트")
+ class UpdateRdbmsTest {
+
+ @Test
+ @DisplayName("인스턴스 수정 시 생명주기 상태가 DB에 업데이트된다")
+ void updateRdbms_Success_UpdatesLifecycleState() {
+ // Given
+ RdbmsUpdateRequest request = RdbmsUpdateRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .instanceSize("db.t3.small")
+ .allocatedStorage(50)
+ .build();
+
+ CloudResource mockUpdatedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .lifecycleState(LifecycleState.RUNNING)
+ .build();
+
+ when(managementPort.updateRdbms(any())).thenReturn(mockUpdatedInstance);
+
+ // When
+ CloudResource result = rdbmsUseCaseService.updateRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+ verify(managementPort).updateRdbms(any());
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+ }
+
+ @Test
+ @DisplayName("DB 업데이트 실패해도 CSP 수정은 완료된다")
+ void updateRdbms_DbUpdateFails_CspUpdateSucceeds() {
+ // Given
+ RdbmsUpdateRequest request = RdbmsUpdateRequest.builder()
+ .providerType(PROVIDER_TYPE)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .instanceSize("db.t3.small")
+ .build();
+
+ CloudResource mockUpdatedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .lifecycleState(LifecycleState.RUNNING)
+ .build();
+
+ when(managementPort.updateRdbms(any())).thenReturn(mockUpdatedInstance);
+ // DB 업데이트 실패
+ doThrow(new RuntimeException("DB 업데이트 실패")).when(resourceHelper)
+ .updateLifecycleState(anyString(), any());
+
+ // When
+ CloudResource result = rdbmsUseCaseService.updateRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+ verify(managementPort).updateRdbms(any()); // CSP 수정은 성공
+ verify(resourceHelper).updateLifecycleState(anyString(), any()); // DB 업데이트 시도
+ }
+ }
+
+ @Nested
+ @DisplayName("생명주기 관리 테스트")
+ class LifecycleManagementTest {
+
+ @Test
+ @DisplayName("인스턴스 시작 시 생명주기 상태가 RUNNING으로 업데이트된다")
+ void startInstance_Success_UpdatesLifecycleStateToRunning() {
+ // Given
+ CloudResource mockInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession))
+ .thenReturn(java.util.Optional.of(mockInstance));
+ doNothing().when(lifecyclePort).start(any(), eq(mockSession));
+
+ // When
+ rdbmsUseCaseService.startInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(lifecyclePort).start(any(), eq(mockSession));
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+ }
+
+ @Test
+ @DisplayName("인스턴스 중지 시 생명주기 상태가 STOPPED로 업데이트된다")
+ void stopInstance_Success_UpdatesLifecycleStateToStopped() {
+ // Given
+ CloudResource mockInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession))
+ .thenReturn(java.util.Optional.of(mockInstance));
+ doNothing().when(lifecyclePort).stop(any(), eq(mockSession));
+
+ // When
+ rdbmsUseCaseService.stopInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(lifecyclePort).stop(any(), eq(mockSession));
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED);
+ }
+
+ @Test
+ @DisplayName("인스턴스 재시작 시 생명주기 상태가 RUNNING으로 유지된다")
+ void rebootInstance_Success_MaintainsRunningState() {
+ // Given
+ doNothing().when(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession);
+
+ // When
+ rdbmsUseCaseService.rebootInstance(PROVIDER_TYPE, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession);
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+ }
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java
new file mode 100644
index 00000000..35cce9b1
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceEncryptionTest.java
@@ -0,0 +1,399 @@
+package com.agenticcp.core.domain.cloud.service.rdbms;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.crypto.AesGcmEncryptionService;
+import com.agenticcp.core.common.crypto.EncryptionService;
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.exception.CloudErrorCode;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsCreateCommand;
+import com.agenticcp.core.domain.cloud.port.model.rdbms.RdbmsUpdateCommand;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+import org.junit.jupiter.api.AfterEach;
+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.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.security.SecureRandom;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.*;
+
+/**
+ * RdbmsUseCaseService 암호화/복호화 로직 단위 테스트
+ *
+ * adminPassword 암호화 로직을 검증합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RdbmsUseCaseService 암호화/복호화 테스트")
+class RdbmsUseCaseServiceEncryptionTest {
+
+ @Mock
+ private RdbmsPortRouter portRouter;
+
+ @Mock
+ private CapabilityGuard capabilityGuard;
+
+ @Mock
+ private AccountCredentialManagementPort credentialPort;
+
+ @Mock
+ private CloudResourceManagementHelper resourceHelper;
+
+ @Mock
+ private RdbmsManagementPort managementPort;
+
+ private EncryptionService encryptionService;
+ private RdbmsUseCaseService rdbmsUseCaseService;
+
+ private String tenantKey;
+ private CloudSessionCredential session;
+ private CloudResource mockResource;
+
+ @BeforeEach
+ void setUp() {
+ tenantKey = "test-tenant";
+ TenantContextHolder.setTenantKey(tenantKey);
+
+ // 실제 EncryptionService 사용 (AES-GCM-256)
+ byte[] key = generateKey(32);
+ encryptionService = new AesGcmEncryptionService(key);
+
+ // RdbmsUseCaseService 인스턴스 생성 (EncryptionService 주입)
+ rdbmsUseCaseService = new RdbmsUseCaseService(
+ portRouter,
+ capabilityGuard,
+ credentialPort,
+ resourceHelper,
+ encryptionService
+ );
+
+ // Mock 설정
+ session = mock(CloudSessionCredential.class);
+ mockResource = mock(CloudResource.class);
+
+ // lenient()를 사용하여 일부 테스트에서 사용되지 않을 수 있는 stubbing 허용
+ lenient().when(credentialPort.getSession(eq(tenantKey), anyString(), any(CloudProvider.ProviderType.class)))
+ .thenReturn(session);
+ lenient().when(portRouter.management(any(CloudProvider.ProviderType.class)))
+ .thenReturn(managementPort);
+ lenient().when(mockResource.getResourceId())
+ .thenReturn("test-instance-123");
+ lenient().when(resourceHelper.registerResource(any(), any(), any()))
+ .thenReturn(mockResource);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ /**
+ * AES 키 생성 헬퍼 메서드
+ */
+ private byte[] generateKey(int size) {
+ byte[] key = new byte[size];
+ new SecureRandom().nextBytes(key);
+ return key;
+ }
+
+ @Nested
+ @DisplayName("adminPassword 암호화 테스트 - 생성 시")
+ class CreateRdbmsEncryptionTest {
+
+ @Test
+ @DisplayName("adminPassword가 정상적으로 암호화되어 Command에 전달됨")
+ void shouldEncryptPasswordWhenCreatingRdbms() {
+ // given
+ String plainPassword = "MySecurePassword123!";
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword(plainPassword)
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(managementPort.createRdbms(any(RdbmsCreateCommand.class)))
+ .willReturn(mockResource);
+
+ // when
+ rdbmsUseCaseService.createRdbms(request);
+
+ // then
+ verify(managementPort).createRdbms(argThat(command -> {
+ // Command에 전달된 adminPassword가 암호화되었는지 확인
+ String encryptedPassword = command.adminPassword();
+ assertThat(encryptedPassword).isNotNull();
+ assertThat(encryptedPassword).isNotEqualTo(plainPassword);
+
+ // 암호화된 값이 Base64 형식인지 확인
+ assertThat(encryptedPassword).doesNotContain(plainPassword);
+
+ // 복호화하여 원본과 일치하는지 확인
+ String decrypted = encryptionService.decrypt(encryptedPassword);
+ assertThat(decrypted).isEqualTo(plainPassword);
+
+ return true;
+ }));
+ }
+
+ @Test
+ @DisplayName("암호화 실패 시 ENCRYPTION_FAILED 예외 발생")
+ void shouldThrowExceptionWhenEncryptionFails() {
+ // given
+ EncryptionService failingEncryptionService = mock(EncryptionService.class);
+ RdbmsUseCaseService serviceWithFailingEncryption = new RdbmsUseCaseService(
+ portRouter,
+ capabilityGuard,
+ credentialPort,
+ resourceHelper,
+ failingEncryptionService
+ );
+
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword("MySecurePassword123!")
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(failingEncryptionService.encrypt(anyString()))
+ .willThrow(new RuntimeException("암호화 실패"));
+
+ // when & then
+ assertThatThrownBy(() -> serviceWithFailingEncryption.createRdbms(request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.ENCRYPTION_FAILED);
+ assertThat(be.getMessage()).contains("관리자 패스워드 암호화에 실패했습니다");
+ });
+ }
+ }
+
+ @Nested
+ @DisplayName("adminPassword 암호화 테스트 - 수정 시")
+ class UpdateRdbmsEncryptionTest {
+
+ @Test
+ @DisplayName("adminPassword가 제공되면 암호화되어 Command에 전달됨")
+ void shouldEncryptPasswordWhenUpdatingRdbmsWithPassword() {
+ // given
+ String plainPassword = "NewSecurePassword456!";
+ RdbmsUpdateRequest request = RdbmsUpdateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .instanceId("db-instance-123")
+ .masterPassword(plainPassword)
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(managementPort.updateRdbms(any(RdbmsUpdateCommand.class)))
+ .willReturn(mockResource);
+
+ // when
+ rdbmsUseCaseService.updateRdbms(request);
+
+ // then
+ verify(managementPort).updateRdbms(argThat(command -> {
+ // Command에 전달된 adminPassword가 암호화되었는지 확인
+ String encryptedPassword = command.adminPassword();
+ assertThat(encryptedPassword).isNotNull();
+ assertThat(encryptedPassword).isNotEqualTo(plainPassword);
+
+ // 복호화하여 원본과 일치하는지 확인
+ String decrypted = encryptionService.decrypt(encryptedPassword);
+ assertThat(decrypted).isEqualTo(plainPassword);
+
+ return true;
+ }));
+ }
+
+ @Test
+ @DisplayName("adminPassword가 null이면 Command에 null이 전달됨")
+ void shouldPassNullWhenPasswordNotProvided() {
+ // given
+ RdbmsUpdateRequest request = RdbmsUpdateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .instanceId("db-instance-123")
+ .instanceSize("db.t3.small")
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(managementPort.updateRdbms(any(RdbmsUpdateCommand.class)))
+ .willReturn(mockResource);
+
+ // when
+ rdbmsUseCaseService.updateRdbms(request);
+
+ // then
+ verify(managementPort).updateRdbms(argThat(command -> {
+ // Command에 전달된 adminPassword가 null인지 확인
+ assertThat(command.adminPassword()).isNull();
+ return true;
+ }));
+ }
+
+ @Test
+ @DisplayName("암호화 실패 시 ENCRYPTION_FAILED 예외 발생")
+ void shouldThrowExceptionWhenEncryptionFails() {
+ // given
+ EncryptionService failingEncryptionService = mock(EncryptionService.class);
+ RdbmsUseCaseService serviceWithFailingEncryption = new RdbmsUseCaseService(
+ portRouter,
+ capabilityGuard,
+ credentialPort,
+ resourceHelper,
+ failingEncryptionService
+ );
+
+ RdbmsUpdateRequest request = RdbmsUpdateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .instanceId("db-instance-123")
+ .masterPassword("NewSecurePassword456!")
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(failingEncryptionService.encrypt(anyString()))
+ .willThrow(new RuntimeException("암호화 실패"));
+
+ // when & then
+ assertThatThrownBy(() -> serviceWithFailingEncryption.updateRdbms(request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> {
+ BusinessException be = (BusinessException) exception;
+ assertThat(be.getErrorCode()).isEqualTo(CloudErrorCode.ENCRYPTION_FAILED);
+ assertThat(be.getMessage()).contains("관리자 패스워드 암호화에 실패했습니다");
+ });
+ }
+ }
+
+ @Nested
+ @DisplayName("암호화 라운드트립 테스트")
+ class EncryptionRoundTripTest {
+
+ @Test
+ @DisplayName("암호화 후 복호화하면 원본과 일치함")
+ void shouldDecryptToOriginalPassword() {
+ // given
+ String plainPassword = "TestPassword123!@#";
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword(plainPassword)
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(managementPort.createRdbms(any(RdbmsCreateCommand.class)))
+ .willReturn(mockResource);
+
+ // when
+ rdbmsUseCaseService.createRdbms(request);
+
+ // then
+ verify(managementPort).createRdbms(argThat(command -> {
+ String encryptedPassword = command.adminPassword();
+
+ // 암호화된 값이 원본과 다름
+ assertThat(encryptedPassword).isNotEqualTo(plainPassword);
+
+ // 복호화하면 원본과 일치
+ String decrypted = encryptionService.decrypt(encryptedPassword);
+ assertThat(decrypted).isEqualTo(plainPassword);
+
+ return true;
+ }));
+ }
+
+ @Test
+ @DisplayName("다양한 특수문자를 포함한 패스워드도 정상 암호화/복호화됨")
+ void shouldHandleSpecialCharacters() {
+ // given
+ String[] testPasswords = {
+ "Password123!@#$%^&*()",
+ "한글패스워드123!",
+ "P@ssw0rd with spaces",
+ "VeryLongPassword1234567890!@#$%^&*()_+-=[]{}|;:,.<>?",
+ "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+ };
+
+ ArgumentCaptor commandCaptor =
+ ArgumentCaptor.forClass(RdbmsCreateCommand.class);
+
+ for (String plainPassword : testPasswords) {
+ RdbmsCreateRequest request = RdbmsCreateRequest.builder()
+ .providerType(CloudProvider.ProviderType.AWS)
+ .accountScope("123456789012")
+ .region("us-east-1")
+ .instanceName("test-db")
+ .engine("mysql")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword(plainPassword)
+ .build();
+
+ doNothing().when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+ given(managementPort.createRdbms(any(RdbmsCreateCommand.class)))
+ .willReturn(mockResource);
+
+ // when
+ rdbmsUseCaseService.createRdbms(request);
+
+ // then - 각 호출마다 검증
+ verify(managementPort).createRdbms(commandCaptor.capture());
+ RdbmsCreateCommand capturedCommand = commandCaptor.getValue();
+
+ String encryptedPassword = capturedCommand.adminPassword();
+ assertThat(encryptedPassword).isNotNull();
+ assertThat(encryptedPassword).isNotEqualTo(plainPassword);
+
+ // 복호화하여 원본과 일치하는지 확인
+ String decrypted = encryptionService.decrypt(encryptedPassword);
+ assertThat(decrypted).isEqualTo(plainPassword);
+
+ // 다음 반복을 위해 reset
+ reset(managementPort);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java
new file mode 100644
index 00000000..c88822c7
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/rdbms/RdbmsUseCaseServiceTest.java
@@ -0,0 +1,623 @@
+package com.agenticcp.core.domain.cloud.service.rdbms;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.common.crypto.EncryptionService;
+import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential;
+import com.agenticcp.core.domain.cloud.capability.CapabilityGuard;
+import com.agenticcp.core.domain.cloud.dto.RdbmsCreateRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsDeleteRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsQueryRequest;
+import com.agenticcp.core.domain.cloud.dto.RdbmsUpdateRequest;
+import com.agenticcp.core.domain.cloud.entity.CloudProvider;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.cloud.entity.CloudResource.LifecycleState;
+import com.agenticcp.core.domain.cloud.port.model.ResourceIdentity;
+import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
+import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsDiscoveryPort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsLifecyclePort;
+import com.agenticcp.core.domain.cloud.port.outbound.rdbms.RdbmsManagementPort;
+import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper;
+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.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * RdbmsUseCaseService 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("RdbmsUseCaseService 테스트")
+class RdbmsUseCaseServiceTest {
+
+ @Mock
+ private RdbmsPortRouter portRouter;
+
+ @Mock
+ private CapabilityGuard capabilityGuard;
+
+ @Mock
+ private AccountCredentialManagementPort credentialPort;
+
+ @Mock
+ private CloudResourceManagementHelper resourceHelper;
+
+ @Mock
+ private EncryptionService encryptionService;
+
+ @Mock
+ private RdbmsManagementPort managementPort;
+
+ @Mock
+ private RdbmsDiscoveryPort discoveryPort;
+
+ @Mock
+ private RdbmsLifecyclePort lifecyclePort;
+
+ @InjectMocks
+ private RdbmsUseCaseService rdbmsUseCaseService;
+
+ private static final String TENANT_KEY = "test-tenant";
+ private static final CloudProvider.ProviderType AWS = CloudProvider.ProviderType.AWS;
+ private static final String ACCOUNT_SCOPE = "123456789012";
+ private static final String INSTANCE_ID = "test-instance-123";
+ private static final String REGION = "us-east-1";
+
+ private CloudSessionCredential mockSession;
+
+ @BeforeEach
+ void setUp() {
+ lenient().when(portRouter.management(AWS)).thenReturn(managementPort);
+ lenient().when(portRouter.discovery(AWS)).thenReturn(discoveryPort);
+ lenient().when(portRouter.lifecycle(AWS)).thenReturn(lifecyclePort);
+
+ mockSession = AwsSessionCredential.builder()
+ .accessKeyId("AKIA_TEST")
+ .secretAccessKey("secret")
+ .sessionToken("token")
+ .region(REGION)
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .build();
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 목록 조회 테스트")
+ class ListRdbmsInstancesTest {
+
+ private RdbmsQueryRequest query;
+ private Page expectedPage;
+
+ @BeforeEach
+ void setUp() {
+ query = RdbmsQueryRequest.builder()
+ .providerType(AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .page(0)
+ .size(10)
+ .instanceName("test")
+ .engine("mysql")
+ .build();
+
+ CloudResource instance1 = CloudResource.builder()
+ .resourceId("instance-1")
+ .resourceName("test-instance-1")
+ .build();
+
+ CloudResource instance2 = CloudResource.builder()
+ .resourceId("instance-2")
+ .resourceName("test-instance-2")
+ .build();
+
+ expectedPage = new PageImpl<>(List.of(instance1, instance2), PageRequest.of(0, 10), 2);
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 목록 조회")
+ void listRdbmsInstances_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.listRdbmsInstances(any(), eq(mockSession))).thenReturn(expectedPage);
+
+ // When
+ Page result = rdbmsUseCaseService.listRdbmsInstances(AWS, ACCOUNT_SCOPE, query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getTotalElements()).isEqualTo(2);
+ assertThat(result.getContent()).hasSize(2);
+ assertThat(result.getContent().get(0).getResourceName()).isEqualTo("test-instance-1");
+
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(discoveryPort).listRdbmsInstances(any(), eq(mockSession));
+ }
+ }
+
+ @Test
+ @DisplayName("빈 목록 조회")
+ void listRdbmsInstances_EmptyResult() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0);
+ when(discoveryPort.listRdbmsInstances(any(), eq(mockSession))).thenReturn(emptyPage);
+
+ // When
+ Page result = rdbmsUseCaseService.listRdbmsInstances(AWS, ACCOUNT_SCOPE, query);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getTotalElements()).isEqualTo(0);
+ assertThat(result.getContent()).isEmpty();
+
+ verify(discoveryPort).listRdbmsInstances(any(), eq(mockSession));
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 조회 테스트")
+ class GetRdbmsInstanceTest {
+
+ private CloudResource expectedInstance;
+
+ @BeforeEach
+ void setUp() {
+ expectedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .displayName("Test RDBMS Instance")
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 조회")
+ void getRdbmsInstance_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession))
+ .thenReturn(Optional.of(expectedInstance));
+
+ // When
+ Optional result = rdbmsUseCaseService.getRdbmsInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getResourceId()).isEqualTo(INSTANCE_ID);
+ assertThat(result.get().getResourceName()).isEqualTo("test-instance");
+
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession);
+ }
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 인스턴스 조회 시 Optional.empty() 반환")
+ void getRdbmsInstance_NotFound_ReturnsEmpty() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession)).thenReturn(Optional.empty());
+
+ // When
+ Optional result = rdbmsUseCaseService.getRdbmsInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ assertThat(result).isEmpty();
+
+ verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 생성 테스트")
+ class CreateRdbmsTest {
+
+ private RdbmsCreateRequest request;
+ private CloudResource expectedInstance;
+
+ @BeforeEach
+ void setUp() {
+ Map tags = new HashMap<>();
+ tags.put("Environment", "test");
+ tags.put("Project", "agenticcp");
+
+ request = RdbmsCreateRequest.builder()
+ .providerType(AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .region(REGION)
+ .instanceName("test-instance")
+ .engine("mysql")
+ .engineVersion("8.0")
+ .instanceSize("db.t3.micro")
+ .allocatedStorage(20)
+ .masterUsername("admin")
+ .masterPassword("password123")
+ .dbName("testdb")
+ .tags(tags)
+ .build();
+
+ expectedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .displayName("Test RDBMS Instance")
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 생성")
+ void createRdbms_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(encryptionService.encrypt("password123")).thenReturn("encrypted-password");
+ when(managementPort.createRdbms(any())).thenReturn(expectedInstance);
+ when(resourceHelper.registerResource(any(), any(), any())).thenReturn(expectedInstance);
+
+ // When
+ CloudResource result = rdbmsUseCaseService.createRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID);
+ assertThat(result.getResourceName()).isEqualTo("test-instance");
+
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.CREATE);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(encryptionService).encrypt("password123");
+ verify(managementPort).createRdbms(any());
+ verify(resourceHelper).registerResource(eq(AWS), eq("RDS"), any());
+ }
+ }
+
+ @Test
+ @DisplayName("Capability 검증 실패 시 예외 발생")
+ void createRdbms_CapabilityCheckFailed_ThrowsException() {
+ // Given
+ doThrow(new RuntimeException("Capability not supported"))
+ .when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Capability not supported");
+
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.CREATE);
+ verify(credentialPort, never()).getSession(any(), any(), any());
+ verify(managementPort, never()).createRdbms(any());
+ }
+
+ @Test
+ @DisplayName("암호화 실패 시 예외 발생")
+ void createRdbms_EncryptionFailed_ThrowsException() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(encryptionService.encrypt("password123"))
+ .thenThrow(new RuntimeException("Encryption failed"));
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Encryption failed");
+
+ verify(encryptionService).encrypt("password123");
+ verify(managementPort, never()).createRdbms(any());
+ }
+ }
+
+ @Test
+ @DisplayName("DB 저장 실패 시 보상 트랜잭션 실행")
+ void createRdbms_DbSaveFailed_ExecutesCompensatingTransaction() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(encryptionService.encrypt("password123")).thenReturn("encrypted-password");
+ when(managementPort.createRdbms(any())).thenReturn(expectedInstance);
+ when(resourceHelper.registerResource(any(), any(), any()))
+ .thenThrow(new RuntimeException("DB save failed"));
+ doNothing().when(managementPort).deleteRdbms(any());
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.createRdbms(request))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("RDBMS 인스턴스 생성 후 DB 저장 실패");
+
+ verify(managementPort).createRdbms(any());
+ verify(resourceHelper).registerResource(any(), any(), any());
+ verify(managementPort).deleteRdbms(any()); // 보상 트랜잭션 확인
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 수정 테스트")
+ class UpdateRdbmsTest {
+
+ private RdbmsUpdateRequest request;
+ private CloudResource expectedInstance;
+
+ @BeforeEach
+ void setUp() {
+ Map tags = new HashMap<>();
+ tags.put("Environment", "production");
+ tags.put("Updated", "true");
+
+ request = RdbmsUpdateRequest.builder()
+ .providerType(AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .instanceSize("db.t3.small")
+ .allocatedStorage(50)
+ .tagsToAdd(tags)
+ .build();
+
+ expectedInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .displayName("Updated Test RDBMS Instance")
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 수정")
+ void updateRdbms_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(managementPort.updateRdbms(any())).thenReturn(expectedInstance);
+
+ // When
+ CloudResource result = rdbmsUseCaseService.updateRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID);
+ assertThat(result.getDisplayName()).isEqualTo("Updated Test RDBMS Instance");
+
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.UPDATE);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(managementPort).updateRdbms(any());
+ }
+ }
+
+ @Test
+ @DisplayName("패스워드 변경 포함 수정")
+ void updateRdbms_WithPasswordChange_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ request.setMasterPassword("newpassword123");
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(encryptionService.encrypt("newpassword123")).thenReturn("encrypted-new-password");
+ when(managementPort.updateRdbms(any())).thenReturn(expectedInstance);
+
+ // When
+ CloudResource result = rdbmsUseCaseService.updateRdbms(request);
+
+ // Then
+ assertThat(result).isNotNull();
+ verify(encryptionService).encrypt("newpassword123");
+ verify(managementPort).updateRdbms(any());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 삭제 테스트")
+ class DeleteRdbmsTest {
+
+ private RdbmsDeleteRequest request;
+
+ @BeforeEach
+ void setUp() {
+ request = RdbmsDeleteRequest.builder()
+ .providerType(AWS)
+ .accountScope(ACCOUNT_SCOPE)
+ .instanceId(INSTANCE_ID)
+ .skipSnapshot(false)
+ .snapshotName("final-snapshot")
+ .deleteAutomatedBackups(false)
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 삭제")
+ void deleteRdbms_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ doNothing().when(managementPort).deleteRdbms(any());
+ doNothing().when(resourceHelper).softDeleteResource(INSTANCE_ID);
+
+ // When
+ rdbmsUseCaseService.deleteRdbms(request);
+
+ // Then
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.TERMINATE);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(managementPort).deleteRdbms(any());
+ verify(resourceHelper).softDeleteResource(INSTANCE_ID);
+ }
+ }
+
+ @Test
+ @DisplayName("Capability 검증 실패 시 예외 발생")
+ void deleteRdbms_CapabilityCheckFailed_ThrowsException() {
+ // Given
+ doThrow(new RuntimeException("Delete capability not supported"))
+ .when(capabilityGuard).ensureSupported(any(), any(), any(), any());
+
+ // When & Then
+ assertThatThrownBy(() -> rdbmsUseCaseService.deleteRdbms(request))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Delete capability not supported");
+
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.TERMINATE);
+ verify(credentialPort, never()).getSession(any(), any(), any());
+ verify(managementPort, never()).deleteRdbms(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 시작 테스트")
+ class StartInstanceTest {
+
+ private CloudResource mockInstance;
+
+ @BeforeEach
+ void setUp() {
+ mockInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 시작")
+ void startInstance_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession))
+ .thenReturn(Optional.of(mockInstance));
+ doNothing().when(lifecyclePort).start(any(ResourceIdentity.class), eq(mockSession));
+ doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+
+ // When
+ rdbmsUseCaseService.startInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.START);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession);
+ verify(lifecyclePort).start(any(ResourceIdentity.class), eq(mockSession));
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 중지 테스트")
+ class StopInstanceTest {
+
+ private CloudResource mockInstance;
+
+ @BeforeEach
+ void setUp() {
+ mockInstance = CloudResource.builder()
+ .resourceId(INSTANCE_ID)
+ .resourceName("test-instance")
+ .build();
+ }
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 중지")
+ void stopInstance_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.getRdbmsInstance(INSTANCE_ID, mockSession))
+ .thenReturn(Optional.of(mockInstance));
+ doNothing().when(lifecyclePort).stop(any(ResourceIdentity.class), eq(mockSession));
+ doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED);
+
+ // When
+ rdbmsUseCaseService.stopInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.STOP);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(discoveryPort).getRdbmsInstance(INSTANCE_ID, mockSession);
+ verify(lifecyclePort).stop(any(ResourceIdentity.class), eq(mockSession));
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.STOPPED);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 재시작 테스트")
+ class RebootInstanceTest {
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 재시작")
+ void rebootInstance_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ doNothing().when(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession);
+ doNothing().when(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+
+ // When
+ rdbmsUseCaseService.rebootInstance(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.STOP);
+ verify(capabilityGuard).ensureSupported(AWS, "RDS", "DATABASE", CapabilityGuard.Operation.START);
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(lifecyclePort).rebootInstance(INSTANCE_ID, mockSession);
+ verify(resourceHelper).updateLifecycleState(INSTANCE_ID, LifecycleState.RUNNING);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("RDBMS 인스턴스 상태 확인 테스트")
+ class GetInstanceStatusTest {
+
+ @Test
+ @DisplayName("정상적인 RDBMS 인스턴스 상태 확인")
+ void getInstanceStatus_Success() {
+ try (MockedStatic mockedStatic = mockStatic(TenantContextHolder.class)) {
+ // Given
+ mockedStatic.when(TenantContextHolder::getCurrentTenantKeyOrThrow).thenReturn(TENANT_KEY);
+ when(credentialPort.getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS)).thenReturn(mockSession);
+ when(discoveryPort.getInstanceStatus(INSTANCE_ID, mockSession)).thenReturn("available");
+
+ // When
+ String result = rdbmsUseCaseService.getInstanceStatus(AWS, ACCOUNT_SCOPE, INSTANCE_ID);
+
+ // Then
+ assertThat(result).isEqualTo("available");
+
+ verify(credentialPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
+ verify(discoveryPort).getInstanceStatus(INSTANCE_ID, mockSession);
+ }
+ }
+ }
+}