initialStatus = new HashMap<>();
// ==================== 편의 메서드 ====================
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
index 2360bc37..f218d12d 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResource.java
@@ -1,375 +1,141 @@
package com.agenticcp.core.domain.cloud.entity;
-import com.agenticcp.core.common.config.TagMapConverter;
import com.agenticcp.core.common.entity.BaseEntity;
-import com.agenticcp.core.common.enums.Status;
import com.agenticcp.core.domain.tenant.entity.Tenant;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
+import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
-import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.util.Map;
-
+/**
+ * Cloud Resource 엔티티 (쿠버네티스 스타일)
+ *
+ * 리소스를 메타데이터가 있는 일반적인 컨테이너로 취급하고,
+ * 구체적인 설정은 구조화된 JSON으로 저장합니다.
+ *
+ * 기본 필드: resourceId, name, provider, region, type, labels (모든 리소스 공통)
+ * 확장 필드: properties (Spec), status (Status) - JSON 형태
+ *
+ * @author AgenticCP Team
+ * @version 2.0.0
+ * @since 2025-01-XX
+ */
@Entity
-@Table(name = "cloud_resources")
+@Table(name = "cloud_resources", indexes = {
+ @Index(name = "idx_resource_id", columnList = "resource_id"),
+ @Index(name = "idx_resource_tenant", columnList = "tenant_id"),
+ @Index(name = "idx_resource_type", columnList = "type"),
+ @Index(name = "idx_resource_tenant_type", columnList = "tenant_id, type")
+})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
+@EqualsAndHashCode(callSuper = false)
public class CloudResource extends BaseEntity {
- @Column(name = "resource_id", nullable = false, unique = true)
+ // ==================== 공통 식별자 ====================
+
+ /**
+ * 리소스 ID (CSP에서 부여한 고유 ID)
+ */
+ @Column(name = "resource_id", nullable = false, unique = true, length = 255)
private String resourceId;
- @Column(name = "resource_name", nullable = false)
- private String resourceName;
-
- @Column(name = "display_name")
- private String displayName;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "provider_id", nullable = false)
- private CloudProvider provider;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "region_id")
- private CloudRegion region;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "service_id", nullable = false)
- private CloudService service;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "tenant_id")
- private Tenant tenant;
-
- @Enumerated(EnumType.STRING)
- @Column(name = "status")
- private Status status = Status.ACTIVE;
-
- @Enumerated(EnumType.STRING)
- @Column(name = "resource_type")
- private ResourceType resourceType;
-
- @Enumerated(EnumType.STRING)
- @Column(name = "lifecycle_state")
- private LifecycleState lifecycleState = LifecycleState.RUNNING;
-
- @Column(name = "instance_type")
- private String instanceType;
-
- @Column(name = "instance_size")
- private String instanceSize;
-
- @Column(name = "cpu_cores")
- private Integer cpuCores;
-
- @Column(name = "memory_gb")
- private Integer memoryGb;
-
- @Column(name = "storage_gb")
- private Long storageGb;
-
- @Column(name = "network_bandwidth_mbps")
- private Integer networkBandwidthMbps;
-
- @Column(name = "ip_address")
- private String ipAddress;
-
- @Column(name = "private_ip_address")
- private String privateIpAddress;
-
- @Column(name = "public_ip_address")
- private String publicIpAddress;
-
- @Convert(converter = TagMapConverter.class)
- @Column(name = "tags", columnDefinition = "TEXT")
- private Map tags;
-
- @Column(name = "configuration", columnDefinition = "TEXT")
- private String configuration; // JSON for resource configuration
-
- @Column(name = "cost_per_hour")
- private BigDecimal costPerHour;
-
- @Column(name = "monthly_cost")
- private BigDecimal monthlyCost;
-
- @Column(name = "created_in_cloud")
- private LocalDateTime createdInCloud;
-
- @Column(name = "last_modified_in_cloud")
- private LocalDateTime lastModifiedInCloud;
-
- @Column(name = "last_sync")
- private LocalDateTime lastSync;
-
- @Column(name = "metadata", columnDefinition = "TEXT")
- private String metadata; // JSON for additional resource metadata
+ /**
+ * 리소스 이름
+ */
+ @Column(name = "name", nullable = false, length = 255)
+ private String name;
- // ==================== Factory Methods ====================
+ /**
+ * 클라우드 프로바이더 (AWS, GCP, Azure 등)
+ */
+ @Column(name = "provider", nullable = false, length = 50)
+ private String provider;
/**
- * 통합 CloudResource 생성 팩토리 메서드
- *
- * 모든 리소스 타입(VM, Storage, VPC, RDS 등)을 하나의 메서드로 생성합니다.
- * 도메인별 상세 속성은 ResourceRegistrationRequest의 attributes에서 추출합니다.
- *
- * @param request 리소스 등록 요청 DTO
- * @param provider 클라우드 프로바이더
- * @param service 클라우드 서비스
- * @param tenant 테넌트
- * @return CloudResource 엔티티
+ * 리전
*/
- public static CloudResource create(
- ResourceRegistrationRequest request,
- CloudProvider provider,
- CloudService service,
- Tenant tenant
- ) {
- LocalDateTime now = LocalDateTime.now();
-
- CloudResource resource = CloudResource.builder()
- .resourceId(request.getResourceId())
- .resourceName(request.getResourceName())
- .displayName(request.getResourceName())
- .provider(provider)
- .service(service)
- .tenant(tenant)
- .status(Status.ACTIVE)
- .resourceType(request.getResourceType())
- .lifecycleState(determineInitialLifecycleState(request))
- .tags(request.getTags())
- .createdInCloud(now)
- .lastModifiedInCloud(now)
- .lastSync(now)
- .build();
-
- // 도메인별 속성 적용
- applyAttributes(resource, request);
-
- return resource;
- }
+ @Column(name = "region", nullable = false, length = 50)
+ private String region;
/**
- * 초기 생명주기 상태 결정
- * 요청에 명시된 상태가 있으면 사용, 없으면 리소스 타입에 따라 기본값 적용
+ * 리소스 타입 (INSTANCE, CLUSTER, BUCKET 등)
*/
- private static LifecycleState determineInitialLifecycleState(ResourceRegistrationRequest request) {
- if (request.getInitialLifecycleState() != null) {
- return request.getInitialLifecycleState();
- }
-
- // 리소스 타입별 기본 생명주기 상태
- return switch (request.getResourceType()) {
- case INSTANCE -> LifecycleState.PENDING;
- default -> LifecycleState.RUNNING;
- };
- }
+ @Column(name = "type", nullable = false, length = 50)
+ private String type;
/**
- * 도메인별 속성을 CloudResource에 적용
+ * 테넌트 (리소스 소유 테넌트)
*/
- private static void applyAttributes(CloudResource resource, ResourceRegistrationRequest request) {
- // instanceSize
- String instanceSize = request.getAttributeAsString(
- ResourceRegistrationRequest.AttributeKeys.INSTANCE_SIZE);
- if (instanceSize != null) {
- resource.setInstanceSize(instanceSize);
- }
-
- // configuration (cidrBlock, JSON 설정 등)
- String configuration = request.getAttributeAsString(
- ResourceRegistrationRequest.AttributeKeys.CONFIGURATION);
- if (configuration != null) {
- resource.setConfiguration(configuration);
- }
-
- // cpuCores
- Integer cpuCores = request.getAttributeAsInteger(
- ResourceRegistrationRequest.AttributeKeys.CPU_CORES);
- if (cpuCores != null) {
- resource.setCpuCores(cpuCores);
- }
-
- // memoryGb
- Integer memoryGb = request.getAttributeAsInteger(
- ResourceRegistrationRequest.AttributeKeys.MEMORY_GB);
- if (memoryGb != null) {
- resource.setMemoryGb(memoryGb);
- }
-
- // storageGb
- Long storageGb = request.getAttributeAsLong(
- ResourceRegistrationRequest.AttributeKeys.STORAGE_GB);
- if (storageGb != null) {
- resource.setStorageGb(storageGb);
- }
-
- // instanceType
- String instanceType = request.getAttributeAsString(
- ResourceRegistrationRequest.AttributeKeys.INSTANCE_TYPE);
- if (instanceType != null) {
- resource.setInstanceType(instanceType);
- }
- }
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id", nullable = false)
+ private Tenant tenant;
+ // ==================== 확장 필드 (JSON) ====================
+
/**
- * VM 인스턴스용 CloudResource 생성
- * CSP에서 생성된 VM 인스턴스 정보를 CloudResource 엔티티로 변환합니다.
- *
- * @param resourceId 인스턴스 ID (CSP에서 부여한 ID)
- * @param resourceName 리소스 이름 (태그에서 추출 또는 resourceId)
- * @param provider 클라우드 프로바이더
- * @param service 클라우드 서비스 (EC2, Compute Engine 등)
- * @param tenant 테넌트
- * @param instanceSize 인스턴스 크기
- * @param tags 태그 맵
- * @return CloudResource 엔티티
+ * 쿠버네티스 Spec - 선언된 상태/설정 (리소스 타입별 설정 정보)
+ * JSON 형태로 저장: { "cpuCores": 4, "memoryGb": 8, "instanceType": "t3.medium" }
*/
- public static CloudResource createVmInstance(
- String resourceId,
- String resourceName,
- CloudProvider provider,
- CloudService service,
- Tenant tenant,
- String instanceSize,
- Map tags
- ) {
- LocalDateTime now = LocalDateTime.now();
- return CloudResource.builder()
- .resourceId(resourceId)
- .resourceName(resourceName)
- .displayName(resourceName)
- .provider(provider)
- .service(service)
- .tenant(tenant)
- .status(Status.ACTIVE)
- .resourceType(ResourceType.INSTANCE)
- .lifecycleState(LifecycleState.PENDING)
- .instanceSize(instanceSize)
- .tags(tags)
- .createdInCloud(now)
- .lastModifiedInCloud(now)
- .lastSync(now)
- .build();
- }
+ @Column(name = "properties", columnDefinition = "JSON")
+ private String properties;
/**
- * Object Storage (버킷/컨테이너)용 CloudResource 생성
- * CSP에서 생성된 스토리지 컨테이너 정보를 CloudResource 엔티티로 변환합니다.
- *
- * @param containerName 컨테이너 이름 (S3 버킷명, Azure Blob 컨테이너명 등)
- * @param provider 클라우드 프로바이더
- * @param service 클라우드 서비스 (S3, BlobStorage 등)
- * @param tenant 테넌트
- * @param tags 태그 맵
- * @return CloudResource 엔티티
+ * 쿠버네티스 Status - 관측된 상태/런타임 정보
+ * JSON 형태로 저장: { "state": "running", "ipAddress": "10.0.0.1", "costPerHour": 0.05 }
*/
- public static CloudResource createStorageBucket(
- String containerName,
- CloudProvider provider,
- CloudService service,
- Tenant tenant,
- Map tags
- ) {
- LocalDateTime now = LocalDateTime.now();
- return CloudResource.builder()
- .resourceId(containerName)
- .resourceName(containerName)
- .displayName(containerName)
- .provider(provider)
- .service(service)
- .tenant(tenant)
- .status(Status.ACTIVE)
- .resourceType(ResourceType.BUCKET)
- .lifecycleState(LifecycleState.RUNNING)
- .tags(tags)
- .createdInCloud(now)
- .lastModifiedInCloud(now)
- .lastSync(now)
- .build();
- }
+ @Column(name = "status", columnDefinition = "JSON")
+ private String status;
/**
- * VPC 네트워크용 CloudResource 생성
- * CSP에서 생성된 VPC 정보를 CloudResource 엔티티로 변환합니다.
- *
- * @param vpcId VPC ID (CSP에서 부여한 ID)
- * @param resourceName 리소스 이름 (VPC 이름 또는 vpcId)
- * @param provider 클라우드 프로바이더
- * @param service 클라우드 서비스 (EC2, VirtualNetwork 등)
- * @param tenant 테넌트
- * @param cidrBlock CIDR 블록 (configuration에 저장)
- * @param tags 태그 맵
- * @return CloudResource 엔티티
+ * 태그/라벨 (JSON Map)
+ * JSON 형태로 저장: { "environment": "production", "team": "backend" }
*/
- public static CloudResource createVpc(
- String vpcId,
- String resourceName,
- CloudProvider provider,
- CloudService service,
- Tenant tenant,
- String cidrBlock,
- Map tags
- ) {
- LocalDateTime now = LocalDateTime.now();
- return CloudResource.builder()
- .resourceId(vpcId)
- .resourceName(resourceName)
- .displayName(resourceName)
- .provider(provider)
- .service(service)
- .tenant(tenant)
- .status(Status.ACTIVE)
- .resourceType(ResourceType.NETWORK)
- .lifecycleState(LifecycleState.RUNNING)
- .tags(tags)
- .configuration(cidrBlock)
- .createdInCloud(now)
- .lastModifiedInCloud(now)
- .lastSync(now)
- .build();
- }
+ @Column(name = "labels", columnDefinition = "JSON")
+ private String labels;
+ // ==================== 내부 Enum ====================
+
+ /**
+ * 리소스 타입
+ */
public enum ResourceType {
INSTANCE,
- VOLUME,
- SNAPSHOT,
- IMAGE,
NETWORK,
- SUBNET,
- SECURITY_GROUP,
- LOAD_BALANCER,
- DATABASE,
BUCKET,
- FUNCTION,
- CONTAINER,
CLUSTER,
- NODE,
- POD,
- SERVICE,
- INGRESS,
- CONFIG_MAP,
- SECRET,
- PERSISTENT_VOLUME,
- PERSISTENT_VOLUME_CLAIM
+ DATABASE,
+ LOAD_BALANCER,
+ SECURITY_GROUP,
+ SUBNET,
+ ROUTE_TABLE,
+ INTERNET_GATEWAY,
+ NAT_GATEWAY,
+ VPC_ENDPOINT,
+ OTHER
}
+ /**
+ * 리소스 생명주기 상태
+ */
public enum LifecycleState {
+ UNKNOWN,
PENDING,
RUNNING,
- STOPPING,
STOPPED,
- TERMINATING,
- TERMINATED,
- FAILED,
- UNKNOWN
+ TERMINATED;
+
+ /**
+ * LifecycleState를 소문자 문자열로 변환합니다.
+ *
+ * @return 소문자 상태 문자열 (예: "running", "stopped")
+ */
+ public String toLowerCase() {
+ return name().toLowerCase();
+ }
}
}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java
new file mode 100644
index 00000000..21cb34dd
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/entity/CloudResourceWorkerMap.java
@@ -0,0 +1,65 @@
+package com.agenticcp.core.domain.cloud.entity;
+
+import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.domain.user.entity.Worker;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * Cloud Resource Worker Map 엔티티
+ *
+ * 리소스-워커 접근 권한 매핑을 담당하는 엔티티입니다.
+ * 쿼리 레벨 필터링을 위해 사용됩니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Entity
+@Table(name = "cloud_resource_worker_map", indexes = {
+ @Index(name = "idx_crwm_resource", columnList = "cloud_resource_id"),
+ @Index(name = "idx_crwm_worker", columnList = "worker_id"),
+ @Index(name = "idx_crwm_access_type", columnList = "access_type"),
+ @Index(name = "idx_crwm_expires", columnList = "expires_at"),
+ @Index(name = "idx_crwm_worker_deleted", columnList = "worker_id, is_deleted")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "uk_crwm_resource_worker", columnNames = {"cloud_resource_id", "worker_id", "is_deleted"})
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = false)
+public class CloudResourceWorkerMap extends BaseEntity {
+
+ @NotNull(message = "Cloud Resource는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "cloud_resource_id", nullable = false)
+ private CloudResource cloudResource;
+
+ @NotNull(message = "Worker는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "worker_id", nullable = false)
+ private Worker worker;
+
+ @NotNull(message = "접근 타입은 필수입니다")
+ @Column(name = "access_type", nullable = false, length = 50)
+ private String accessType; // CREATOR, ORGANIZATION, GRANTED
+
+ @Column(name = "grant_reason", length = 255)
+ private String grantReason;
+
+ @Column(name = "granted_by", length = 255)
+ private String grantedBy;
+
+ @Column(name = "expires_at")
+ private LocalDateTime expiresAt;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
index 86363501..d00a95bc 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceRepository.java
@@ -2,8 +2,6 @@
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.entity.CloudResource.ResourceType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -37,7 +35,6 @@ public interface CloudResourceRepository extends JpaRepository findByTenantKey(@Param("tenantKey") String tenantKey);
@@ -60,7 +57,6 @@ public interface CloudResourceRepository extends JpaRepository findOptionalByResourceId(@Param("resourceId") String resourceId);
@@ -81,130 +77,98 @@ public interface CloudResourceRepository extends JpaRepository findByResourceType(@Param("resourceType") ResourceType resourceType);
+ List findByResourceType(@Param("resourceType") String resourceType);
/**
* 테넌트 키 + 리소스 타입별 조회
*
* @param tenantKey 테넌트 키
- * @param resourceType 리소스 타입
+ * @param resourceType 리소스 타입 (String)
* @return 클라우드 리소스 목록
*/
@Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider " +
"WHERE cr.tenant.tenantKey = :tenantKey " +
- "AND cr.resourceType = :resourceType " +
+ "AND cr.type = :resourceType " +
"AND cr.isDeleted = false")
List findByTenantKeyAndResourceType(
@Param("tenantKey") String tenantKey,
- @Param("resourceType") ResourceType resourceType);
+ @Param("resourceType") String resourceType);
// ==================== 프로바이더(CSP)별 조회 ====================
/**
- * 프로바이더 타입별 조회 (AWS, Azure, GCP 등)
+ * 프로바이더별 조회 (AWS, Azure, GCP 등)
+ * 쿠버네티스 스타일: provider 필드 (String) 사용
*
- * @param providerType 프로바이더 타입
+ * @param provider 프로바이더 (String, 예: "AWS", "AZURE", "GCP")
* @return 클라우드 리소스 목록
*/
@Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider p " +
- "WHERE p.providerType = :providerType " +
+ "WHERE cr.provider = :provider " +
"AND cr.isDeleted = false")
- List findByProviderType(@Param("providerType") ProviderType providerType);
+ List findByProvider(@Param("provider") String provider);
/**
- * 테넌트 키 + 프로바이더 타입별 조회
+ * 테넌트 키 + 프로바이더별 조회
*
* @param tenantKey 테넌트 키
- * @param providerType 프로바이더 타입
+ * @param provider 프로바이더 (String)
* @return 클라우드 리소스 목록
*/
@Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider p " +
"WHERE cr.tenant.tenantKey = :tenantKey " +
- "AND p.providerType = :providerType " +
+ "AND cr.provider = :provider " +
"AND cr.isDeleted = false")
- List findByTenantKeyAndProviderType(
+ List findByTenantKeyAndProvider(
@Param("tenantKey") String tenantKey,
- @Param("providerType") ProviderType providerType);
-
- // ==================== 생명주기 상태별 조회 ====================
-
- /**
- * 생명주기 상태별 조회
- *
- * @param lifecycleState 생명주기 상태 (RUNNING, STOPPED 등)
- * @return 클라우드 리소스 목록
- */
- @Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider " +
- "WHERE cr.lifecycleState = :lifecycleState " +
- "AND cr.isDeleted = false")
- List findByLifecycleState(@Param("lifecycleState") LifecycleState lifecycleState);
-
- /**
- * 특정 생명주기 상태를 제외한 조회 (동기화용)
- * TERMINATED 상태를 제외한 활성 리소스 조회 등에 활용
- *
- * @param excludeStates 제외할 상태 목록
- * @return 클라우드 리소스 목록
- */
- @Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider " +
- "WHERE cr.lifecycleState NOT IN :excludeStates " +
- "AND cr.isDeleted = false")
- List findByLifecycleStateNotIn(@Param("excludeStates") List excludeStates);
+ @Param("provider") String provider);
/**
- * 테넌트 키 + 프로바이더 타입 + 리소스 타입별 조회
+ * 테넌트 키 + 프로바이더 + 리소스 타입별 조회
*
* @param tenantKey 테넌트 키
- * @param providerType 프로바이더 타입
- * @param resourceType 리소스 타입
+ * @param provider 프로바이더 (String)
+ * @param resourceType 리소스 타입 (String)
* @return 클라우드 리소스 목록
*/
@Query("SELECT cr FROM CloudResource cr " +
- "JOIN FETCH cr.provider p " +
"WHERE cr.tenant.tenantKey = :tenantKey " +
- "AND p.providerType = :providerType " +
- "AND cr.resourceType = :resourceType " +
+ "AND cr.provider = :provider " +
+ "AND cr.type = :resourceType " +
"AND cr.isDeleted = false")
- List findByTenantKeyAndProviderTypeAndResourceType(
+ List findByTenantKeyAndProviderAndResourceType(
@Param("tenantKey") String tenantKey,
- @Param("providerType") ProviderType providerType,
- @Param("resourceType") ResourceType resourceType);
+ @Param("provider") String provider,
+ @Param("resourceType") String resourceType);
// ==================== 상태 업데이트 (Modifying) ====================
/**
- * 생명주기 상태 업데이트
+ * 상태 업데이트 (쿠버네티스 스타일)
* CSP에서 자원 시작/중지/종료 후 DB 상태 동기화에 사용
+ * status 필드 (JSON)에 상태 정보를 저장합니다.
*
* @param resourceId 리소스 ID
- * @param lifecycleState 새로운 생명주기 상태
- * @param lastModifiedInCloud 클라우드에서 수정된 시간
+ * @param status 상태 정보 (JSON String)
* @return 업데이트된 행 수
*/
@Modifying
@Query("UPDATE CloudResource cr SET " +
- "cr.lifecycleState = :lifecycleState, " +
- "cr.lastModifiedInCloud = :lastModifiedInCloud, " +
+ "cr.status = :status, " +
"cr.updatedAt = CURRENT_TIMESTAMP " +
"WHERE cr.resourceId = :resourceId " +
"AND cr.isDeleted = false")
- int updateLifecycleState(
+ int updateStatus(
@Param("resourceId") String resourceId,
- @Param("lifecycleState") LifecycleState lifecycleState,
- @Param("lastModifiedInCloud") LocalDateTime lastModifiedInCloud);
+ @Param("status") String status);
/**
* 소프트 삭제 처리
@@ -216,7 +180,6 @@ int updateLifecycleState(
@Modifying
@Query("UPDATE CloudResource cr SET " +
"cr.isDeleted = true, " +
- "cr.lifecycleState = 'TERMINATED', " +
"cr.updatedAt = CURRENT_TIMESTAMP " +
"WHERE cr.resourceId = :resourceId")
int softDeleteByResourceId(@Param("resourceId") String resourceId);
@@ -252,15 +215,14 @@ int updateLastSync(
long countByTenantKey(@Param("tenantKey") String tenantKey);
/**
- * 프로바이더 타입별 리소스 수 조회
+ * 프로바이더별 리소스 수 조회
*
- * @param providerType 프로바이더 타입
+ * @param provider 프로바이더 (String)
* @return 리소스 수
*/
@Query("SELECT COUNT(cr) FROM CloudResource cr " +
- "JOIN cr.provider p " +
- "WHERE p.providerType = :providerType " +
+ "WHERE cr.provider = :provider " +
"AND cr.isDeleted = false")
- long countByProviderType(@Param("providerType") ProviderType providerType);
+ long countByProvider(@Param("provider") String provider);
}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java
new file mode 100644
index 00000000..5df68851
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/cloud/repository/CloudResourceWorkerMapRepository.java
@@ -0,0 +1,110 @@
+package com.agenticcp.core.domain.cloud.repository;
+
+import com.agenticcp.core.domain.cloud.entity.CloudResourceWorkerMap;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Cloud Resource Worker Map Repository
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Repository
+public interface CloudResourceWorkerMapRepository extends JpaRepository {
+
+ /**
+ * Cloud Resource ID로 Worker Map 목록 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param cloudResourceId Cloud Resource ID
+ * @return Worker Map 목록
+ */
+ @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " +
+ "WHERE crwm.cloudResource.id = :cloudResourceId " +
+ "AND crwm.isDeleted = false " +
+ "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)")
+ List findByCloudResourceId(
+ @Param("cloudResourceId") Long cloudResourceId,
+ @Param("now") LocalDateTime now
+ );
+
+ /**
+ * Worker ID로 Worker Map 목록 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param workerId Worker ID
+ * @return Worker Map 목록
+ */
+ @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " +
+ "WHERE crwm.worker.id = :workerId " +
+ "AND crwm.isDeleted = false " +
+ "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)")
+ List findByWorkerId(
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+
+ /**
+ * Cloud Resource ID와 Worker ID로 Worker Map 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param cloudResourceId Cloud Resource ID
+ * @param workerId Worker ID
+ * @return Worker Map (Optional)
+ */
+ @Query("SELECT crwm FROM CloudResourceWorkerMap crwm " +
+ "WHERE crwm.cloudResource.id = :cloudResourceId " +
+ "AND crwm.worker.id = :workerId " +
+ "AND crwm.isDeleted = false " +
+ "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)")
+ Optional findByCloudResourceIdAndWorkerId(
+ @Param("cloudResourceId") Long cloudResourceId,
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+
+ /**
+ * Worker가 접근 가능한 Cloud Resource ID 목록 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param workerId Worker ID
+ * @return Cloud Resource ID 목록
+ */
+ @Query("SELECT crwm.cloudResource.id FROM CloudResourceWorkerMap crwm " +
+ "WHERE crwm.worker.id = :workerId " +
+ "AND crwm.isDeleted = false " +
+ "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)")
+ List findCloudResourceIdsByWorkerId(
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+
+ /**
+ * Worker가 특정 Cloud Resource에 접근 가능한지 확인
+ * 만료되지 않은 것만 확인
+ *
+ * @param cloudResourceId Cloud Resource ID
+ * @param workerId Worker ID
+ * @return 접근 가능 여부
+ */
+ @Query("SELECT CASE WHEN COUNT(crwm) > 0 THEN true ELSE false END " +
+ "FROM CloudResourceWorkerMap crwm " +
+ "WHERE crwm.cloudResource.id = :cloudResourceId " +
+ "AND crwm.worker.id = :workerId " +
+ "AND crwm.isDeleted = false " +
+ "AND (crwm.expiresAt IS NULL OR crwm.expiresAt > :now)")
+ boolean existsByCloudResourceIdAndWorkerId(
+ @Param("cloudResourceId") Long cloudResourceId,
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java b/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java
index 7c5d78da..6c4930c9 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/helper/CloudResourceManagementHelper.java
@@ -5,8 +5,8 @@
import com.agenticcp.core.domain.cloud.entity.CloudProvider;
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.entity.CloudService;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository;
import com.agenticcp.core.domain.cloud.repository.CloudResourceRepository;
import com.agenticcp.core.domain.cloud.repository.CloudServiceRepository;
@@ -64,11 +64,47 @@ public CloudResource registerResource(
String serviceKey,
ResourceRegistrationRequest request
) {
- CloudProvider provider = findProvider(providerType);
- CloudService service = findServiceOrCreate(providerType, serviceKey, provider);
Tenant tenant = findCurrentTenant();
- CloudResource cloudResource = CloudResource.create(request, provider, service, tenant);
+ // 쿠버네티스 스타일: CloudResource 직접 생성
+ CloudResource cloudResource = CloudResource.builder()
+ .resourceId(request.getResourceId())
+ .name(request.getResourceName())
+ .provider(providerType.name())
+ .region("us-east-1") // TODO: request에서 region 추출
+ .type(request.getResourceType())
+ .tenant(tenant)
+ .build();
+
+ // properties (Spec) 구성
+ if (!request.getAttributes().isEmpty()) {
+ try {
+ String propertiesJson = objectMapper.writeValueAsString(request.getAttributes());
+ cloudResource.setProperties(propertiesJson);
+ } catch (Exception e) {
+ log.warn("[CloudResourceManagementHelper] properties JSON 변환 실패: {}", e.getMessage());
+ }
+ }
+
+ // status (Status) 구성
+ if (!request.getInitialStatus().isEmpty()) {
+ try {
+ String statusJson = objectMapper.writeValueAsString(request.getInitialStatus());
+ cloudResource.setStatus(statusJson);
+ } catch (Exception e) {
+ log.warn("[CloudResourceManagementHelper] status JSON 변환 실패: {}", e.getMessage());
+ }
+ }
+
+ // labels 구성
+ if (!request.getTags().isEmpty()) {
+ try {
+ String labelsJson = objectMapper.writeValueAsString(request.getTags());
+ cloudResource.setLabels(labelsJson);
+ } catch (Exception e) {
+ log.warn("[CloudResourceManagementHelper] labels JSON 변환 실패: {}", e.getMessage());
+ }
+ }
CloudResource savedResource = cloudResourceRepository.save(cloudResource);
log.debug("[CloudResourceManagementHelper] 리소스 등록 완료: resourceType={}, resourceId={}",
@@ -78,29 +114,50 @@ public CloudResource registerResource(
// ==================== 공통 작업 ====================
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
/**
- * 리소스의 생명주기 상태를 업데이트합니다.
- *
- * @param resourceId 리소스 ID
- * @param lifecycleState 새로운 생명주기 상태
+ * 리소스의 상태를 업데이트합니다 (쿠버네티스 스타일).
+ *
+ * @param resourceId 리소스 ID
+ * @param state 상태 값 (예: "running", "stopped", "terminated")
*/
- public void updateLifecycleState(String resourceId, LifecycleState lifecycleState) {
+ public void updateLifecycleState(String resourceId, String state) {
try {
- int updatedCount = cloudResourceRepository.updateLifecycleState(
- resourceId, lifecycleState, LocalDateTime.now());
+ // 쿠버네티스 스타일: status 필드에 JSON으로 저장
+ Map statusMap = Map.of("state", state);
+ String statusJson;
+ try {
+ statusJson = objectMapper.writeValueAsString(statusMap);
+ } catch (Exception e) {
+ log.warn("[CloudResourceManagementHelper] JSON 변환 실패: resourceId={}, error={}", resourceId, e.getMessage());
+ return;
+ }
+
+ int updatedCount = cloudResourceRepository.updateStatus(resourceId, statusJson);
if (updatedCount > 0) {
- log.debug("[CloudResourceManagementHelper] 생명주기 상태 업데이트 완료: resourceId={}, state={}",
- resourceId, lifecycleState);
+ log.debug("[CloudResourceManagementHelper] 상태 업데이트 완료: resourceId={}, state={}",
+ resourceId, state);
} else {
log.debug("[CloudResourceManagementHelper] DB에 리소스가 없어 상태 업데이트 스킵: resourceId={}", resourceId);
}
} catch (Exception e) {
- log.warn("[CloudResourceManagementHelper] 생명주기 상태 업데이트 실패: resourceId={}, error={}",
+ log.warn("[CloudResourceManagementHelper] 상태 업데이트 실패: resourceId={}, error={}",
resourceId, e.getMessage());
}
}
+ /**
+ * 리소스의 상태를 업데이트합니다 (LifecycleState enum 사용).
+ *
+ * @param resourceId 리소스 ID
+ * @param lifecycleState 생명주기 상태 enum
+ */
+ public void updateLifecycleState(String resourceId, CloudResource.LifecycleState lifecycleState) {
+ updateLifecycleState(resourceId, lifecycleState.toLowerCase());
+ }
+
/**
* 리소스를 소프트 삭제합니다.
*
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java
index 44c2f564..81307a3d 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseService.java
@@ -96,7 +96,7 @@ public CloudResource createContainer(CreateObjectStorageContainerRequest request
ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder()
.resourceId(request.getContainerName())
.resourceName(request.getContainerName())
- .resourceType(CloudResource.ResourceType.BUCKET)
+ .resourceType("BUCKET")
.tags(request.getTags())
.build();
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java
index 0ff3fdf9..b8eaf2ab 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseService.java
@@ -10,7 +10,6 @@
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.VmQuery;
import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential;
@@ -147,7 +146,7 @@ public CloudResource createInstance(VmCreateRequest request) {
ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder()
.resourceId(instanceId)
.resourceName(resourceName)
- .resourceType(CloudResource.ResourceType.INSTANCE)
+ .resourceType("INSTANCE")
.tags(request.getTags())
.attributes(Map.of(AttributeKeys.INSTANCE_SIZE, request.getInstanceSize()))
.build();
@@ -224,7 +223,7 @@ public void startInstance(ProviderType providerType, String accountScope, String
vmPortRouter.lifecycle(providerType).startInstance(instanceId, session);
// DB 상태 업데이트: RUNNING
- resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING);
+ resourceHelper.updateLifecycleState(instanceId, "running");
log.info("VM 인스턴스 시작 완료: provider={}, instanceId={}", providerType, instanceId);
}
@@ -251,7 +250,7 @@ public void stopInstance(ProviderType providerType, String accountScope, String
vmPortRouter.lifecycle(providerType).stopInstance(instanceId, session);
// DB 상태 업데이트: STOPPED
- resourceHelper.updateLifecycleState(instanceId, LifecycleState.STOPPED);
+ resourceHelper.updateLifecycleState(instanceId, "stopped");
log.info("VM 인스턴스 중지 완료: provider={}, instanceId={}", providerType, instanceId);
}
@@ -279,7 +278,7 @@ public void rebootInstance(ProviderType providerType, String accountScope, Strin
vmPortRouter.lifecycle(providerType).rebootInstance(instanceId, session);
// DB 상태 업데이트: 재부팅 후 RUNNING 상태 유지 (lastModifiedInCloud만 업데이트)
- resourceHelper.updateLifecycleState(instanceId, LifecycleState.RUNNING);
+ resourceHelper.updateLifecycleState(instanceId, "running");
log.info("VM 인스턴스 재부팅 완료: provider={}, instanceId={}", providerType, instanceId);
}
@@ -306,7 +305,7 @@ public void terminateInstance(ProviderType providerType, String accountScope, St
vmPortRouter.lifecycle(providerType).terminateInstance(instanceId, session);
// DB 상태 업데이트: TERMINATED
- resourceHelper.updateLifecycleState(instanceId, LifecycleState.TERMINATED);
+ resourceHelper.updateLifecycleState(instanceId, "terminated");
log.info("VM 인스턴스 종료 완료: provider={}, instanceId={}", providerType, instanceId);
}
diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java
index f78a9f07..867c6afb 100644
--- a/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java
+++ b/src/main/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseService.java
@@ -114,7 +114,7 @@ public CloudResource createVpc(VpcCreateRequest request) {
ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder()
.resourceId(vpc.getResourceId())
.resourceName(resourceName)
- .resourceType(CloudResource.ResourceType.NETWORK)
+ .resourceType("NETWORK")
.tags(request.getTags())
.attributes(Map.of(AttributeKeys.CONFIGURATION, request.getCidrBlock()))
.build();
diff --git a/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java b/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java
new file mode 100644
index 00000000..a4ecf35b
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/organization/entity/OrganizationUser.java
@@ -0,0 +1,57 @@
+package com.agenticcp.core.domain.organization.entity;
+
+import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.domain.user.entity.User;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+/**
+ * Organization User 엔티티
+ *
+ * 조직-사용자 매핑을 담당하는 엔티티입니다.
+ * Organization ↔ User (M:N) 관계를 관리합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Entity
+@Table(name = "organization_users", indexes = {
+ @Index(name = "idx_ou_organization", columnList = "organization_id"),
+ @Index(name = "idx_ou_user", columnList = "user_id"),
+ @Index(name = "idx_ou_status", columnList = "status")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "uk_ou_organization_user", columnNames = {"organization_id", "user_id", "is_deleted"})
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = false)
+public class OrganizationUser extends BaseEntity {
+
+ @NotNull(message = "Organization은 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "organization_id", nullable = false)
+ private Organization organization;
+
+ @NotNull(message = "User는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Column(name = "org_role", length = 50)
+ private String orgRole; // 조직 내 역할 (ORG_ADMIN, ORG_MEMBER 등)
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ @Builder.Default
+ private Status status = Status.ACTIVE;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java
new file mode 100644
index 00000000..b3abf3bf
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/organization/repository/OrganizationUserRepository.java
@@ -0,0 +1,83 @@
+package com.agenticcp.core.domain.organization.repository;
+
+import com.agenticcp.core.domain.organization.entity.OrganizationUser;
+import com.agenticcp.core.common.enums.Status;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Organization User Repository
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Repository
+public interface OrganizationUserRepository extends JpaRepository {
+
+ /**
+ * Organization ID로 OrganizationUser 목록 조회
+ *
+ * @param organizationId Organization ID
+ * @return OrganizationUser 목록
+ */
+ List findByOrganizationIdAndIsDeletedFalse(Long organizationId);
+
+ /**
+ * User ID로 OrganizationUser 목록 조회
+ *
+ * @param userId User ID
+ * @return OrganizationUser 목록
+ */
+ List findByUserIdAndIsDeletedFalse(Long userId);
+
+ /**
+ * Organization ID와 User ID로 OrganizationUser 조회
+ *
+ * @param organizationId Organization ID
+ * @param userId User ID
+ * @return OrganizationUser (Optional)
+ */
+ Optional findByOrganizationIdAndUserIdAndIsDeletedFalse(Long organizationId, Long userId);
+
+ /**
+ * User가 속한 Organization ID 목록 조회
+ *
+ * @param userId User ID
+ * @return Organization ID 목록
+ */
+ @Query("SELECT DISTINCT ou.organization.id FROM OrganizationUser ou " +
+ "WHERE ou.user.id = :userId AND ou.isDeleted = false")
+ List findOrganizationIdsByUserId(@Param("userId") Long userId);
+
+ /**
+ * User가 특정 Organization에 속하는지 확인
+ *
+ * @param userId User ID
+ * @param organizationId Organization ID
+ * @return 존재 여부
+ */
+ boolean existsByUserIdAndOrganizationIdAndIsDeletedFalse(Long userId, Long organizationId);
+
+ /**
+ * Organization ID와 Status로 OrganizationUser 목록 조회
+ *
+ * @param organizationId Organization ID
+ * @param status Status
+ * @return OrganizationUser 목록
+ */
+ @Query("SELECT ou FROM OrganizationUser ou " +
+ "WHERE ou.organization.id = :organizationId " +
+ "AND ou.status = :status " +
+ "AND ou.isDeleted = false")
+ List findByOrganizationIdAndStatus(
+ @Param("organizationId") Long organizationId,
+ @Param("status") Status status
+ );
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java b/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java
index ea24a8ac..917c0a75 100644
--- a/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java
+++ b/src/main/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentService.java
@@ -62,7 +62,7 @@ public MultiCloudEnvironment detectEnvironment(String tenantId) {
}
return true;
})
- .map(r -> r.getProvider().getProviderType().name())
+ .map(r -> r.getProvider()) // provider는 이미 String
.collect(Collectors.toSet());
if (providers.isEmpty()) {
diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java b/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java
new file mode 100644
index 00000000..444e6452
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/user/entity/Worker.java
@@ -0,0 +1,76 @@
+package com.agenticcp.core.domain.user.entity;
+
+import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.common.enums.Status;
+import com.agenticcp.core.domain.organization.entity.Organization;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Worker 엔티티
+ *
+ * User와 Tenant를 연결하는 엔티티입니다.
+ * User ↔ Worker (1:N): 한 User는 여러 Worker를 가질 수 있음
+ * Worker → Tenant (N:1): Worker는 하나의 Tenant에만 속함
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Entity
+@Table(name = "workers", indexes = {
+ @Index(name = "idx_workers_tenant", columnList = "tenant_id"),
+ @Index(name = "idx_workers_user", columnList = "user_id"),
+ @Index(name = "idx_workers_organization", columnList = "organization_id"),
+ @Index(name = "idx_workers_key", columnList = "worker_key"),
+ @Index(name = "idx_workers_user_tenant", columnList = "user_id, tenant_id")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "uk_workers_key", columnNames = "worker_key")
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = false)
+public class Worker extends BaseEntity {
+
+ @NotBlank(message = "Worker 키는 필수입니다")
+ @Column(name = "worker_key", nullable = false, unique = true, length = 100)
+ private String workerKey;
+
+ @NotBlank(message = "Worker 이름은 필수입니다")
+ @Column(name = "worker_name", nullable = false, length = 255)
+ private String workerName;
+
+ @NotNull(message = "Tenant는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id", nullable = false)
+ private Tenant tenant;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "organization_id")
+ private Organization organization;
+
+ @NotNull(message = "User는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false)
+ @Builder.Default
+ private Status status = Status.ACTIVE;
+
+ @OneToMany(mappedBy = "worker", fetch = FetchType.LAZY)
+ private List roleAssignments;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java b/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java
new file mode 100644
index 00000000..fd53e6a4
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/user/entity/WorkerRoleAssignment.java
@@ -0,0 +1,67 @@
+package com.agenticcp.core.domain.user.entity;
+
+import com.agenticcp.core.common.entity.BaseEntity;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * Worker Role Assignment 엔티티
+ *
+ * Worker에 Role을 할당하는 엔티티입니다.
+ * Tenant 스코프를 포함하여 멀티 테넌트 환경에서 Worker의 Role을 관리합니다.
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Entity
+@Table(name = "worker_role_assignments", indexes = {
+ @Index(name = "idx_wra_tenant", columnList = "tenant_id"),
+ @Index(name = "idx_wra_worker", columnList = "worker_id"),
+ @Index(name = "idx_wra_role", columnList = "role_id"),
+ @Index(name = "idx_wra_tenant_worker", columnList = "tenant_id, worker_id"),
+ @Index(name = "idx_wra_expires", columnList = "expires_at")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "uk_wra_tenant_worker_role", columnNames = {"tenant_id", "worker_id", "role_id", "is_deleted"})
+})
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@EqualsAndHashCode(callSuper = false)
+public class WorkerRoleAssignment extends BaseEntity {
+
+ @NotNull(message = "Tenant는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "tenant_id", nullable = false)
+ private Tenant tenant;
+
+ @NotNull(message = "Worker는 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "worker_id", nullable = false)
+ private Worker worker;
+
+ @NotNull(message = "Role은 필수입니다")
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "role_id", nullable = false)
+ private Role role;
+
+ @Column(name = "assigned_by", length = 255)
+ private String assignedBy;
+
+ @Column(name = "assigned_at", nullable = false)
+ @Builder.Default
+ private LocalDateTime assignedAt = LocalDateTime.now();
+
+ @Column(name = "expires_at")
+ private LocalDateTime expiresAt;
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java
new file mode 100644
index 00000000..ccbf4e01
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRepository.java
@@ -0,0 +1,74 @@
+package com.agenticcp.core.domain.user.repository;
+
+import com.agenticcp.core.domain.user.entity.Worker;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Worker Repository
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Repository
+public interface WorkerRepository extends JpaRepository {
+
+ /**
+ * User ID로 Worker 목록 조회
+ *
+ * @param userId User ID
+ * @return Worker 목록
+ */
+ List findByUserIdAndIsDeletedFalse(Long userId);
+
+ /**
+ * User ID와 Tenant ID로 Worker 조회
+ *
+ * @param userId User ID
+ * @param tenantId Tenant ID
+ * @return Worker (Optional)
+ */
+ Optional findByUserIdAndTenantIdAndIsDeletedFalse(Long userId, Long tenantId);
+
+ /**
+ * Tenant ID로 Worker 목록 조회
+ *
+ * @param tenantId Tenant ID
+ * @return Worker 목록
+ */
+ List findByTenantIdAndIsDeletedFalse(Long tenantId);
+
+ /**
+ * Worker Key로 Worker 조회
+ *
+ * @param workerKey Worker Key
+ * @return Worker (Optional)
+ */
+ Optional findByWorkerKeyAndIsDeletedFalse(String workerKey);
+
+ /**
+ * User가 속한 모든 Tenant ID 목록 조회
+ *
+ * @param userId User ID
+ * @return Tenant ID 목록
+ */
+ @Query("SELECT DISTINCT w.tenant.id FROM Worker w WHERE w.user.id = :userId AND w.isDeleted = false")
+ List findTenantIdsByUserId(@Param("userId") Long userId);
+
+ /**
+ * User가 특정 Tenant에 속하는지 확인
+ *
+ * @param userId User ID
+ * @param tenantId Tenant ID
+ * @return 존재 여부
+ */
+ boolean existsByUserIdAndTenantIdAndIsDeletedFalse(Long userId, Long tenantId);
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java
new file mode 100644
index 00000000..e7977dac
--- /dev/null
+++ b/src/main/java/com/agenticcp/core/domain/user/repository/WorkerRoleAssignmentRepository.java
@@ -0,0 +1,60 @@
+package com.agenticcp.core.domain.user.repository;
+
+import com.agenticcp.core.domain.user.entity.WorkerRoleAssignment;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Worker Role Assignment Repository
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ * @since 2025-01-XX
+ */
+@Repository
+public interface WorkerRoleAssignmentRepository extends JpaRepository {
+
+ /**
+ * Tenant ID와 Worker ID로 Role Assignment 목록 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param tenantId Tenant ID
+ * @param workerId Worker ID
+ * @return Role Assignment 목록
+ */
+ @Query("SELECT wra FROM WorkerRoleAssignment wra " +
+ "WHERE wra.tenant.id = :tenantId " +
+ "AND wra.worker.id = :workerId " +
+ "AND wra.isDeleted = false " +
+ "AND (wra.expiresAt IS NULL OR wra.expiresAt > :now)")
+ List findByTenantIdAndWorkerId(
+ @Param("tenantId") Long tenantId,
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+
+ /**
+ * Tenant ID와 Worker ID로 Role ID 목록 조회
+ * 만료되지 않은 것만 조회
+ *
+ * @param tenantId Tenant ID
+ * @param workerId Worker ID
+ * @return Role ID 목록
+ */
+ @Query("SELECT wra.role.id FROM WorkerRoleAssignment wra " +
+ "WHERE wra.tenant.id = :tenantId " +
+ "AND wra.worker.id = :workerId " +
+ "AND wra.isDeleted = false " +
+ "AND (wra.expiresAt IS NULL OR wra.expiresAt > :now)")
+ List findRoleIdsByTenantIdAndWorkerId(
+ @Param("tenantId") Long tenantId,
+ @Param("workerId") Long workerId,
+ @Param("now") LocalDateTime now
+ );
+}
+
diff --git a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
index 6370b633..22603b85 100644
--- a/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
+++ b/src/main/java/com/agenticcp/core/domain/user/service/PermissionService.java
@@ -11,15 +11,19 @@
import com.agenticcp.core.domain.user.dto.PermissionResponse;
import com.agenticcp.core.domain.user.dto.UpdatePermissionRequest;
import com.agenticcp.core.domain.user.entity.Permission;
+import com.agenticcp.core.domain.user.entity.Role;
import com.agenticcp.core.domain.user.repository.PermissionRepository;
import com.agenticcp.core.domain.user.repository.RoleRepository;
+import com.agenticcp.core.domain.user.repository.WorkerRoleAssignmentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -37,6 +41,7 @@ public class PermissionService {
private final PermissionRepository permissionRepository;
private final RoleRepository roleRepository;
+ private final WorkerRoleAssignmentRepository workerRoleAssignmentRepository;
/**
* 모든 권한 조회 (현재 테넌트)
@@ -359,5 +364,151 @@ public PermissionResponse toPermissionResponse(Permission permission) {
.updatedBy(permission.getUpdatedBy())
.build();
}
+
+ /**
+ * 권한 체크 메서드
+ *
+ * Worker가 특정 리소스에 대해 특정 액션을 수행할 권한이 있는지 확인합니다.
+ *
+ * 권한 체크 흐름:
+ *
+ * - Tenant 격리 확인: resource.tenantId == tenantId
+ * - Worker의 Role 조회 (WorkerRoleAssignment)
+ * - Role의 Permission 확인 (Role.permissions)
+ * - resourceType과 action에 대한 권한 확인
+ *
+ *
+ * @param workerId Worker ID (null이면 TenantContextHolder에서 가져옴)
+ * @param tenantId Tenant ID (null이면 TenantContextHolder에서 가져옴)
+ * @param action 액션 (예: "START", "STOP", "DELETE")
+ * @param resource 리소스 객체 (tenantId, type 필드 필요)
+ * @return 권한 있음: true, 권한 없음: false
+ */
+ @Transactional(readOnly = true)
+ public boolean can(Long workerId, Long tenantId, String action, Object resource) {
+ // workerId나 tenantId가 null이면 TenantContextHolder에서 가져오기
+ if (workerId == null || tenantId == null) {
+ TenantContextHolder.TenantWorkerContext context = TenantContextHolder.getCurrentTenantAndWorkerOrThrow();
+ if (workerId == null) {
+ workerId = context.getWorkerId();
+ }
+ if (tenantId == null) {
+ tenantId = context.getTenantId();
+ }
+ }
+ log.debug("Permission check: workerId={}, tenantId={}, action={}", workerId, tenantId, action);
+
+ // 1단계: Tenant 격리 확인
+ Long resourceTenantId = extractTenantId(resource);
+ if (resourceTenantId == null || !resourceTenantId.equals(tenantId)) {
+ log.warn("Tenant isolation violation: resource.tenantId={}, tenantId={}", resourceTenantId, tenantId);
+ return false;
+ }
+
+ // 2단계: Worker의 Role 조회
+ List roleIds = workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(
+ tenantId, workerId, LocalDateTime.now()
+ );
+
+ if (roleIds.isEmpty()) {
+ log.debug("No roles assigned to worker: workerId={}, tenantId={}", workerId, tenantId);
+ return false;
+ }
+
+ // 3단계: Role의 Permission 확인
+ List roles = roleRepository.findAllById(roleIds);
+ Set permissionIds = roles.stream()
+ .flatMap(role -> role.getPermissions().stream())
+ .map(Permission::getId)
+ .collect(Collectors.toSet());
+
+ if (permissionIds.isEmpty()) {
+ log.debug("No permissions found for roles: roleIds={}", roleIds);
+ return false;
+ }
+
+ // 4단계: resourceType과 action에 대한 권한 확인
+ String resourceType = extractResourceType(resource);
+ List matchingPermissions = permissionRepository.findAllById(permissionIds).stream()
+ .filter(permission ->
+ resourceType != null && resourceType.equals(permission.getResource()) &&
+ action != null && action.equals(permission.getAction())
+ )
+ .collect(Collectors.toList());
+
+ boolean hasPermission = !matchingPermissions.isEmpty();
+ log.debug("Permission check result: workerId={}, tenantId={}, action={}, resourceType={}, hasPermission={}",
+ workerId, tenantId, action, resourceType, hasPermission);
+
+ return hasPermission;
+ }
+
+ /**
+ * 리소스에서 Tenant ID 추출
+ *
+ * @param resource 리소스 객체
+ * @return Tenant ID (없으면 null)
+ */
+ private Long extractTenantId(Object resource) {
+ if (resource == null) {
+ return null;
+ }
+
+ try {
+ // 리소스가 tenantId 필드를 가진 경우
+ java.lang.reflect.Field tenantIdField = resource.getClass().getDeclaredField("tenantId");
+ tenantIdField.setAccessible(true);
+ Object tenantId = tenantIdField.get(resource);
+
+ if (tenantId instanceof Long) {
+ return (Long) tenantId;
+ }
+
+ // tenant 필드를 가진 경우 (Tenant 엔티티)
+ java.lang.reflect.Field tenantField = resource.getClass().getDeclaredField("tenant");
+ tenantField.setAccessible(true);
+ Object tenant = tenantField.get(resource);
+
+ if (tenant instanceof Tenant) {
+ return ((Tenant) tenant).getId();
+ }
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ log.debug("Failed to extract tenantId from resource: {}", e.getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * 리소스에서 Resource Type 추출
+ *
+ * @param resource 리소스 객체
+ * @return Resource Type (없으면 null)
+ */
+ private String extractResourceType(Object resource) {
+ if (resource == null) {
+ return null;
+ }
+
+ try {
+ // type 필드 확인
+ java.lang.reflect.Field typeField = resource.getClass().getDeclaredField("type");
+ typeField.setAccessible(true);
+ Object type = typeField.get(resource);
+
+ if (type instanceof String) {
+ return (String) type;
+ }
+
+ // Enum인 경우
+ if (type instanceof Enum) {
+ return ((Enum>) type).name();
+ }
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ log.debug("Failed to extract type from resource: {}", e.getMessage());
+ }
+
+ return null;
+ }
}
diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java
new file mode 100644
index 00000000..d9733fa6
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/context/TenantContextHolderTest.java
@@ -0,0 +1,231 @@
+package com.agenticcp.core.common.context;
+
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import com.agenticcp.core.domain.user.entity.Worker;
+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 static org.assertj.core.api.Assertions.*;
+
+/**
+ * TenantContextHolder 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@DisplayName("TenantContextHolder 단위 테스트")
+class TenantContextHolderTest {
+
+ private Tenant testTenant;
+ private Worker testWorker;
+ private User testUser;
+
+ @BeforeEach
+ void setUp() {
+ testUser = User.builder()
+ .username("testuser")
+ .build();
+ testUser.setId(1L);
+
+ testTenant = Tenant.builder()
+ .tenantKey("tenant-1")
+ .tenantName("Tenant 1")
+ .build();
+ testTenant.setId(100L);
+ testTenant.setIsDeleted(false);
+
+ testWorker = Worker.builder()
+ .workerKey("worker-1")
+ .user(testUser)
+ .tenant(testTenant)
+ .build();
+ testWorker.setId(10L);
+ testWorker.setIsDeleted(false);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Nested
+ @DisplayName("Tenant 컨텍스트 관리 테스트")
+ class TenantContextTest {
+
+ @Test
+ @DisplayName("Tenant를 설정하고 조회할 수 있어야 한다")
+ void setTenant_ThenGetCurrentTenant_ReturnsTenant() {
+ // When
+ TenantContextHolder.setTenant(testTenant);
+ Tenant result = TenantContextHolder.getCurrentTenant();
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(100L);
+ assertThat(result.getTenantKey()).isEqualTo("tenant-1");
+ }
+
+ @Test
+ @DisplayName("Tenant Key를 설정하고 조회할 수 있어야 한다")
+ void setTenantKey_ThenGetCurrentTenantKey_ReturnsTenantKey() {
+ // When
+ TenantContextHolder.setTenantKey("tenant-1");
+ String result = TenantContextHolder.getCurrentTenantKey();
+
+ // Then
+ assertThat(result).isEqualTo("tenant-1");
+ }
+
+ @Test
+ @DisplayName("Tenant가 설정되지 않았으면 getCurrentTenantOrThrow는 예외를 발생시켜야 한다")
+ void getCurrentTenantOrThrow_WithoutTenant_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(TenantContextHolder::getCurrentTenantOrThrow)
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("Tenant Key가 설정되지 않았으면 getCurrentTenantKeyOrThrow는 예외를 발생시켜야 한다")
+ void getCurrentTenantKeyOrThrow_WithoutTenantKey_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(TenantContextHolder::getCurrentTenantKeyOrThrow)
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("clear()를 호출하면 Tenant 컨텍스트가 제거되어야 한다")
+ void clear_RemovesTenantContext() {
+ // Given
+ TenantContextHolder.setTenant(testTenant);
+
+ // When
+ TenantContextHolder.clear();
+
+ // Then
+ assertThat(TenantContextHolder.getCurrentTenant()).isNull();
+ assertThat(TenantContextHolder.getCurrentTenantKey()).isNull();
+ }
+
+ @Test
+ @DisplayName("hasTenantContext()는 Tenant Key가 설정되어 있으면 true를 반환해야 한다")
+ void hasTenantContext_WithTenantKey_ReturnsTrue() {
+ // Given
+ TenantContextHolder.setTenantKey("tenant-1");
+
+ // When
+ boolean result = TenantContextHolder.hasTenantContext();
+
+ // Then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("hasTenantContext()는 Tenant Key가 설정되지 않았으면 false를 반환해야 한다")
+ void hasTenantContext_WithoutTenantKey_ReturnsFalse() {
+ // When
+ boolean result = TenantContextHolder.hasTenantContext();
+
+ // Then
+ assertThat(result).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("Worker 컨텍스트 관리 테스트")
+ class WorkerContextTest {
+
+ @Test
+ @DisplayName("Tenant와 Worker를 함께 설정하고 조회할 수 있어야 한다")
+ void setCurrentTenantAndWorker_ThenGetCurrentWorker_ReturnsWorker() {
+ // When
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+ Worker result = TenantContextHolder.getCurrentWorker();
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(10L);
+ assertThat(result.getWorkerKey()).isEqualTo("worker-1");
+ assertThat(TenantContextHolder.getCurrentTenant().getId()).isEqualTo(100L);
+ }
+
+ @Test
+ @DisplayName("Worker가 설정되지 않았으면 getCurrentWorkerOrThrow는 예외를 발생시켜야 한다")
+ void getCurrentWorkerOrThrow_WithoutWorker_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(TenantContextHolder::getCurrentWorkerOrThrow)
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("Tenant와 Worker를 함께 조회할 수 있어야 한다")
+ void getCurrentTenantAndWorker_ReturnsBoth() {
+ // Given
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+
+ // When
+ TenantContextHolder.TenantWorkerContext context = TenantContextHolder.getCurrentTenantAndWorker();
+
+ // Then
+ assertThat(context).isNotNull();
+ assertThat(context.getTenant()).isNotNull();
+ assertThat(context.getWorker()).isNotNull();
+ assertThat(context.getTenantId()).isEqualTo(100L);
+ assertThat(context.getWorkerId()).isEqualTo(10L);
+ }
+
+ @Test
+ @DisplayName("Tenant나 Worker가 설정되지 않았으면 getCurrentTenantAndWorkerOrThrow는 예외를 발생시켜야 한다")
+ void getCurrentTenantAndWorkerOrThrow_WithoutContext_ThrowsException() {
+ // When & Then
+ assertThatThrownBy(TenantContextHolder::getCurrentTenantAndWorkerOrThrow)
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ @DisplayName("clear()를 호출하면 Worker 컨텍스트도 제거되어야 한다")
+ void clear_RemovesWorkerContext() {
+ // Given
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+
+ // When
+ TenantContextHolder.clear();
+
+ // Then
+ assertThat(TenantContextHolder.getCurrentWorker()).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("ThreadLocal 격리 테스트")
+ class ThreadLocalIsolationTest {
+
+ @Test
+ @DisplayName("다른 스레드에서 설정한 컨텍스트는 현재 스레드에 영향을 주지 않아야 한다")
+ void contextIsolation_BetweenThreads_IsIsolated() throws InterruptedException {
+ // Given
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+
+ // When
+ Thread otherThread = new Thread(() -> {
+ Tenant otherTenant = Tenant.builder()
+ .tenantKey("tenant-2")
+ .build();
+ otherTenant.setId(200L);
+ TenantContextHolder.setTenant(otherTenant);
+ });
+ otherThread.start();
+ otherThread.join();
+
+ // Then
+ Tenant currentTenant = TenantContextHolder.getCurrentTenant();
+ assertThat(currentTenant).isNotNull();
+ assertThat(currentTenant.getId()).isEqualTo(100L); // 원래 스레드의 값 유지
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java
new file mode 100644
index 00000000..bb47d571
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/context/TenantContextInterceptorTest.java
@@ -0,0 +1,290 @@
+package com.agenticcp.core.common.context;
+
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import com.agenticcp.core.domain.user.entity.Worker;
+import com.agenticcp.core.domain.user.repository.UserRepository;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+/**
+ * TenantContextInterceptor 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("TenantContextInterceptor 단위 테스트")
+class TenantContextInterceptorTest {
+
+ @Mock
+ private TenantContextService tenantContextService;
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @InjectMocks
+ private TenantContextInterceptor interceptor;
+
+ private User testUser;
+ private Tenant testTenant;
+ private Worker testWorker;
+
+ @BeforeEach
+ void setUp() {
+ testUser = User.builder()
+ .username("testuser")
+ .build();
+ testUser.setId(1L);
+
+ testTenant = Tenant.builder()
+ .tenantKey("tenant-1")
+ .tenantName("Tenant 1")
+ .build();
+ testTenant.setId(100L);
+ testTenant.setIsDeleted(false);
+
+ testWorker = Worker.builder()
+ .workerKey("worker-1")
+ .user(testUser)
+ .tenant(testTenant)
+ .build();
+ testWorker.setId(10L);
+ testWorker.setIsDeleted(false);
+
+ // SecurityContext 설정
+ Authentication authentication = new UsernamePasswordAuthenticationToken("testuser", null);
+ SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+ securityContext.setAuthentication(authentication);
+ SecurityContextHolder.setContext(securityContext);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ SecurityContextHolder.clearContext();
+ }
+
+ @Nested
+ @DisplayName("정상 처리 테스트")
+ class SuccessTest {
+
+ @Test
+ @DisplayName("X-Tenant-Id 헤더가 있고 User가 Tenant에 속하면 컨텍스트를 설정하고 true를 반환해야 한다")
+ void preHandle_WithValidTenantId_SetsContextAndReturnsTrue() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(request.getHeader("X-Tenant-Id")).thenReturn("100");
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
+ when(tenantContextService.validateTenantAccessOrThrow(1L, 100L))
+ .thenReturn(testWorker);
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ assertThat(TenantContextHolder.getCurrentTenant()).isNotNull();
+ assertThat(TenantContextHolder.getCurrentTenant().getId()).isEqualTo(100L);
+ assertThat(TenantContextHolder.getCurrentWorker()).isNotNull();
+ assertThat(TenantContextHolder.getCurrentWorker().getId()).isEqualTo(10L);
+ verify(tenantContextService).validateTenantAccessOrThrow(1L, 100L);
+ }
+ }
+
+ @Nested
+ @DisplayName("스킵 경로 테스트")
+ class SkipPathTest {
+
+ @Test
+ @DisplayName("/health 경로는 스킵해야 한다")
+ void preHandle_WithHealthPath_Skips() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/health");
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("/auth 경로는 스킵해야 한다")
+ void preHandle_WithAuthPath_Skips() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/auth/login");
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("/swagger 경로는 스킵해야 한다")
+ void preHandle_WithSwaggerPath_Skips() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/swagger-ui/index.html");
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong());
+ }
+ }
+
+ @Nested
+ @DisplayName("인증 없음 테스트")
+ class NoAuthenticationTest {
+
+ @Test
+ @DisplayName("인증이 없으면 스킵해야 한다")
+ void preHandle_WithoutAuthentication_Skips() throws Exception {
+ // Given
+ SecurityContextHolder.clearContext();
+ when(request.getRequestURI()).thenReturn("/api/vms");
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("User를 찾을 수 없으면 스킵해야 한다")
+ void preHandle_WithUserNotFound_Skips() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.empty());
+
+ // When
+ boolean result = interceptor.preHandle(request, response, null);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(tenantContextService, never()).validateTenantAccessOrThrow(anyLong(), anyLong());
+ }
+ }
+
+ @Nested
+ @DisplayName("Tenant ID 헤더 테스트")
+ class TenantIdHeaderTest {
+
+ @Test
+ @DisplayName("X-Tenant-Id 헤더가 없으면 예외를 발생시켜야 한다")
+ void preHandle_WithoutTenantIdHeader_ThrowsException() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(request.getHeader("X-Tenant-Id")).thenReturn(null);
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
+
+ // When & Then
+ assertThatThrownBy(() -> interceptor.preHandle(request, response, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Tenant ID is required");
+ }
+
+ @Test
+ @DisplayName("X-Tenant-Id 헤더가 빈 문자열이면 예외를 발생시켜야 한다")
+ void preHandle_WithEmptyTenantIdHeader_ThrowsException() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(request.getHeader("X-Tenant-Id")).thenReturn("");
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
+
+ // When & Then
+ assertThatThrownBy(() -> interceptor.preHandle(request, response, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Tenant ID is required");
+ }
+
+ @Test
+ @DisplayName("X-Tenant-Id 헤더가 숫자가 아니면 예외를 발생시켜야 한다")
+ void preHandle_WithInvalidTenantIdFormat_ThrowsException() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(request.getHeader("X-Tenant-Id")).thenReturn("invalid");
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
+
+ // When & Then
+ assertThatThrownBy(() -> interceptor.preHandle(request, response, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Invalid tenant ID format");
+ }
+ }
+
+ @Nested
+ @DisplayName("Tenant 접근 검증 실패 테스트")
+ class TenantAccessValidationFailureTest {
+
+ @Test
+ @DisplayName("User가 Tenant에 속하지 않으면 예외를 발생시켜야 한다")
+ void preHandle_WithNoTenantAccess_ThrowsException() throws Exception {
+ // Given
+ when(request.getRequestURI()).thenReturn("/api/vms");
+ when(request.getHeader("X-Tenant-Id")).thenReturn("100");
+ when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser));
+ when(tenantContextService.validateTenantAccessOrThrow(1L, 100L))
+ .thenThrow(new BusinessException(com.agenticcp.core.common.enums.CommonErrorCode.FORBIDDEN, "User is not a member of this tenant"));
+
+ // When & Then
+ assertThatThrownBy(() -> interceptor.preHandle(request, response, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("User is not a member of this tenant");
+ }
+ }
+
+ @Nested
+ @DisplayName("afterCompletion 테스트")
+ class AfterCompletionTest {
+
+ @Test
+ @DisplayName("요청 처리 완료 후 컨텍스트를 정리해야 한다")
+ void afterCompletion_ClearsContext() {
+ // Given
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+
+ // When
+ interceptor.afterCompletion(request, response, null, null);
+
+ // Then
+ assertThat(TenantContextHolder.getCurrentTenant()).isNull();
+ assertThat(TenantContextHolder.getCurrentWorker()).isNull();
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java b/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java
new file mode 100644
index 00000000..dd0d6f80
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/common/context/TenantContextServiceTest.java
@@ -0,0 +1,244 @@
+package com.agenticcp.core.common.context;
+
+import com.agenticcp.core.common.exception.BusinessException;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import com.agenticcp.core.domain.user.entity.Worker;
+import com.agenticcp.core.domain.user.repository.WorkerRepository;
+import com.agenticcp.core.domain.tenant.repository.TenantRepository;
+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.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+/**
+ * TenantContextService 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("TenantContextService 단위 테스트")
+class TenantContextServiceTest {
+
+ @Mock
+ private WorkerRepository workerRepository;
+
+ @Mock
+ private TenantRepository tenantRepository;
+
+ @InjectMocks
+ private TenantContextService tenantContextService;
+
+ private User testUser;
+ private Tenant testTenant1;
+ private Tenant testTenant2;
+ private Worker testWorker1;
+ private Worker testWorker2;
+
+ @BeforeEach
+ void setUp() {
+ testUser = User.builder()
+ .username("testuser")
+ .build();
+ testUser.setId(1L);
+
+ testTenant1 = Tenant.builder()
+ .tenantKey("tenant-1")
+ .tenantName("Tenant 1")
+ .build();
+ testTenant1.setId(100L);
+ testTenant1.setIsDeleted(false);
+
+ testTenant2 = Tenant.builder()
+ .tenantKey("tenant-2")
+ .tenantName("Tenant 2")
+ .build();
+ testTenant2.setId(200L);
+ testTenant2.setIsDeleted(false);
+
+ testWorker1 = Worker.builder()
+ .workerKey("worker-1")
+ .user(testUser)
+ .tenant(testTenant1)
+ .build();
+ testWorker1.setId(10L);
+ testWorker1.setIsDeleted(false);
+
+ testWorker2 = Worker.builder()
+ .workerKey("worker-2")
+ .user(testUser)
+ .tenant(testTenant2)
+ .build();
+ testWorker2.setId(20L);
+ testWorker2.setIsDeleted(false);
+ }
+
+ @Nested
+ @DisplayName("getAvailableTenantIds 테스트")
+ class GetAvailableTenantIdsTest {
+
+ @Test
+ @DisplayName("User가 속한 모든 Tenant ID 목록을 반환해야 한다")
+ void getAvailableTenantIds_WithMultipleTenants_ReturnsAllTenantIds() {
+ // Given
+ when(workerRepository.findTenantIdsByUserId(1L))
+ .thenReturn(Arrays.asList(100L, 200L));
+
+ // When
+ List result = tenantContextService.getAvailableTenantIds(1L);
+
+ // Then
+ assertThat(result).hasSize(2);
+ assertThat(result).containsExactlyInAnyOrder(100L, 200L);
+ verify(workerRepository).findTenantIdsByUserId(1L);
+ }
+
+ @Test
+ @DisplayName("User가 속한 Tenant가 없으면 빈 목록을 반환해야 한다")
+ void getAvailableTenantIds_WithNoTenants_ReturnsEmptyList() {
+ // Given
+ when(workerRepository.findTenantIdsByUserId(1L))
+ .thenReturn(Collections.emptyList());
+
+ // When
+ List result = tenantContextService.getAvailableTenantIds(1L);
+
+ // Then
+ assertThat(result).isEmpty();
+ verify(workerRepository).findTenantIdsByUserId(1L);
+ }
+ }
+
+ @Nested
+ @DisplayName("validateTenantAccess 테스트")
+ class ValidateTenantAccessTest {
+
+ @Test
+ @DisplayName("User가 Tenant에 속하면 Worker를 반환해야 한다")
+ void validateTenantAccess_WithValidAccess_ReturnsWorker() {
+ // Given
+ when(tenantRepository.findById(100L))
+ .thenReturn(Optional.of(testTenant1));
+ when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L))
+ .thenReturn(Optional.of(testWorker1));
+
+ // When
+ Optional result = tenantContextService.validateTenantAccess(1L, 100L);
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getId()).isEqualTo(10L);
+ assertThat(result.get().getTenant().getId()).isEqualTo(100L);
+ verify(tenantRepository).findById(100L);
+ verify(workerRepository).findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L);
+ }
+
+ @Test
+ @DisplayName("Tenant가 존재하지 않으면 빈 Optional을 반환해야 한다")
+ void validateTenantAccess_WithNonExistentTenant_ReturnsEmpty() {
+ // Given
+ when(tenantRepository.findById(999L))
+ .thenReturn(Optional.empty());
+
+ // When
+ Optional result = tenantContextService.validateTenantAccess(1L, 999L);
+
+ // Then
+ assertThat(result).isEmpty();
+ verify(tenantRepository).findById(999L);
+ verify(workerRepository, never()).findByUserIdAndTenantIdAndIsDeletedFalse(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("Tenant가 삭제되었으면 빈 Optional을 반환해야 한다")
+ void validateTenantAccess_WithDeletedTenant_ReturnsEmpty() {
+ // Given
+ Tenant deletedTenant = Tenant.builder()
+ .tenantKey("deleted-tenant")
+ .build();
+ deletedTenant.setId(300L);
+ deletedTenant.setIsDeleted(true);
+ when(tenantRepository.findById(300L))
+ .thenReturn(Optional.of(deletedTenant));
+
+ // When
+ Optional result = tenantContextService.validateTenantAccess(1L, 300L);
+
+ // Then
+ assertThat(result).isEmpty();
+ verify(tenantRepository).findById(300L);
+ verify(workerRepository, never()).findByUserIdAndTenantIdAndIsDeletedFalse(anyLong(), anyLong());
+ }
+
+ @Test
+ @DisplayName("User가 Tenant에 속하지 않으면 빈 Optional을 반환해야 한다")
+ void validateTenantAccess_WithNoAccess_ReturnsEmpty() {
+ // Given
+ when(tenantRepository.findById(100L))
+ .thenReturn(Optional.of(testTenant1));
+ when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L))
+ .thenReturn(Optional.empty());
+
+ // When
+ Optional result = tenantContextService.validateTenantAccess(1L, 100L);
+
+ // Then
+ assertThat(result).isEmpty();
+ verify(tenantRepository).findById(100L);
+ verify(workerRepository).findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L);
+ }
+ }
+
+ @Nested
+ @DisplayName("validateTenantAccessOrThrow 테스트")
+ class ValidateTenantAccessOrThrowTest {
+
+ @Test
+ @DisplayName("User가 Tenant에 속하면 Worker를 반환해야 한다")
+ void validateTenantAccessOrThrow_WithValidAccess_ReturnsWorker() {
+ // Given
+ when(tenantRepository.findById(100L))
+ .thenReturn(Optional.of(testTenant1));
+ when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L))
+ .thenReturn(Optional.of(testWorker1));
+
+ // When
+ Worker result = tenantContextService.validateTenantAccessOrThrow(1L, 100L);
+
+ // Then
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(10L);
+ assertThat(result.getTenant().getId()).isEqualTo(100L);
+ }
+
+ @Test
+ @DisplayName("User가 Tenant에 속하지 않으면 예외를 발생시켜야 한다")
+ void validateTenantAccessOrThrow_WithNoAccess_ThrowsException() {
+ // Given
+ when(tenantRepository.findById(100L))
+ .thenReturn(Optional.of(testTenant1));
+ when(workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(1L, 100L))
+ .thenReturn(Optional.empty());
+
+ // When & Then
+ assertThatThrownBy(() -> tenantContextService.validateTenantAccessOrThrow(1L, 100L))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("User is not a member of this tenant");
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java b/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java
index 46c3e887..64825e30 100644
--- a/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java
+++ b/src/test/java/com/agenticcp/core/common/service/AuthenticationServiceTest.java
@@ -13,6 +13,7 @@
import com.agenticcp.core.domain.tenant.service.TenantService;
import com.agenticcp.core.domain.user.service.UserAuthHistoryService;
import com.agenticcp.core.common.service.TwoFactorService;
+import com.agenticcp.core.common.context.TenantContextService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -54,6 +55,9 @@ class AuthenticationServiceTest {
@Mock
private TwoFactorService twoFactorService;
+ @Mock
+ private TenantContextService tenantContextService;
+
@Mock
private HttpServletRequest httpRequest;
@@ -62,7 +66,7 @@ class AuthenticationServiceTest {
@BeforeEach
void setUp() {
authenticationService = new AuthenticationService(
- userService, jwtService, passwordEncoder, tenantService,
+ userService, tenantContextService, jwtService, passwordEncoder, tenantService,
twoFactorService, authHistoryService
);
}
diff --git a/src/test/java/com/agenticcp/core/controller/VmControllerTest.java b/src/test/java/com/agenticcp/core/controller/VmControllerTest.java
index a43d126d..e65b77d2 100644
--- a/src/test/java/com/agenticcp/core/controller/VmControllerTest.java
+++ b/src/test/java/com/agenticcp/core/controller/VmControllerTest.java
@@ -63,8 +63,10 @@ void setUp() {
testInstance = CloudResource.builder()
.resourceId("i-1234567890abcdef0")
- .resourceName("test-instance")
- .displayName("Test Instance")
+ .name("test-instance")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
}
@@ -98,7 +100,7 @@ void setUp() {
mockMvc.perform(get(BASE_URL + "/{instanceId}", "AWS", "123456789012", "i-1234567890abcdef0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.resourceId").value("i-1234567890abcdef0"))
- .andExpect(jsonPath("$.data.resourceName").value("test-instance"));
+ .andExpect(jsonPath("$.data.name").value("test-instance"));
}
@Test
@@ -131,7 +133,7 @@ void setUp() {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.resourceId").value("i-1234567890abcdef0"))
- .andExpect(jsonPath("$.data.resourceName").value("test-instance"));
+ .andExpect(jsonPath("$.data.name").value("test-instance"));
}
@Test
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java
index 671338c2..64919cdb 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketDiscoveryAdapterTest.java
@@ -104,7 +104,7 @@ void listBuckets_Success() throws Exception {
CloudResource mockResource = CloudResource.builder()
.resourceId("bucket-" + BUCKET_NAME)
- .resourceName(BUCKET_NAME)
+ .name(BUCKET_NAME)
.build();
when(mapper.toCloudResource(any(), any())).thenReturn(mockResource);
@@ -127,7 +127,7 @@ void listBuckets_Success() throws Exception {
Page result = adapter.listContainers(query);
assertThat(result.getContent()).hasSize(1);
- assertThat(result.getContent().get(0).getResourceName()).isEqualTo(BUCKET_NAME);
+ assertThat(result.getContent().get(0).getName()).isEqualTo(BUCKET_NAME);
verify(accountCredentialManagementPort).getSession(eq(TENANT_KEY), eq(ACCOUNT_SCOPE), eq(CloudProvider.ProviderType.AWS));
verify(taggingClient).getResources(any(GetResourcesRequest.class));
}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java
index 262c745a..b332452f 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/s3/AwsS3BucketManagementAdapterTest.java
@@ -95,7 +95,10 @@ void createContainer_Success() {
CloudResource mockResource = CloudResource.builder()
.resourceId(CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
+ .name(CONTAINER_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
when(mapper.toCloudResource(any(Bucket.class), any(CloudProvider.class))).thenReturn(mockResource);
@@ -108,7 +111,7 @@ void createContainer_Success() {
// Then
assertThat(result).isNotNull();
- assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME);
+ assertThat(result.getName()).isEqualTo(CONTAINER_NAME);
verify(awsS3Config).createS3Client(eq(mockSession), eq("us-east-1"));
verify(s3Client).createBucket(any(CreateBucketRequest.class));
@@ -237,7 +240,10 @@ void updateContainer_Success() {
CloudResource mockResource = CloudResource.builder()
.resourceId(CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
+ .name(CONTAINER_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
when(mapper.toCloudResource(any(Bucket.class), any(CloudProvider.class))).thenReturn(mockResource);
@@ -251,7 +257,7 @@ void updateContainer_Success() {
// Then
assertThat(result).isNotNull();
- assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME);
+ assertThat(result.getName()).isEqualTo(CONTAINER_NAME);
verify(awsS3Config).createS3Client(eq(mockSession), isNull());
verify(s3Client).headBucket(any(HeadBucketRequest.class));
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java b/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java
index e648af3d..607cece3 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/port/VmManagementContractTest.java
@@ -56,8 +56,10 @@ class VmManagementContractTest {
void setUp() {
testInstance = CloudResource.builder()
.resourceId("i-1234567890abcdef0")
- .resourceName("test-instance")
- .displayName("Test Instance")
+ .name("test-instance")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
testQuery = VmQuery.builder()
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java
index 2e945062..76a60902 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceCreateTest.java
@@ -91,7 +91,10 @@ void tearDown() {
String expectedInstanceId = "i-1234567890abcdef0";
CloudResource expectedCloudResource = CloudResource.builder()
.resourceId(expectedInstanceId)
- .resourceName(expectedInstanceId)
+ .name(expectedInstanceId)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId);
@@ -126,7 +129,10 @@ void tearDown() {
String expectedInstanceId = "i-abcdef1234567890";
CloudResource expectedCloudResource = CloudResource.builder()
.resourceId(expectedInstanceId)
- .resourceName(expectedInstanceId)
+ .name(expectedInstanceId)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId);
@@ -193,7 +199,10 @@ void tearDown() {
String expectedInstanceId = "i-tagged1234567890";
CloudResource expectedCloudResource = CloudResource.builder()
.resourceId(expectedInstanceId)
- .resourceName("test-instance")
+ .name("test-instance")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
when(vmLifecyclePort.createInstance(any(VmCreateCommand.class))).thenReturn(expectedInstanceId);
@@ -208,7 +217,7 @@ void tearDown() {
// Then
assertThat(result).isNotNull();
assertThat(result.getResourceId()).isEqualTo(expectedInstanceId);
- assertThat(result.getResourceName()).isEqualTo("test-instance");
+ assertThat(result.getName()).isEqualTo("test-instance");
// 포트 호출 확인
verify(vmLifecyclePort).createInstance(any(VmCreateCommand.class));
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java
index a12da12c..ae4942d5 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/aws/VmUseCaseServiceTest.java
@@ -100,7 +100,7 @@ void tearDown() {
CloudResource resource = CloudResource.builder()
.resourceId("i-1234567890abcdef0")
- .resourceName("test-instance")
+ .name("test-instance")
.build();
Page expectedPage = new PageImpl<>(
@@ -128,7 +128,7 @@ void tearDown() {
CloudResource resource = CloudResource.builder()
.resourceId(instanceId)
- .resourceName("test-instance")
+ .name("test-instance")
.build();
when(vmDiscoveryPort.getInstance(eq(instanceId), any(CloudSessionCredential.class))).thenReturn(Optional.of(resource));
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java
index 251a0f3b..f50ae097 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/ObjectStorageUseCaseServiceDbSyncTest.java
@@ -115,7 +115,7 @@ void createContainer_Success_SavesCloudResource() {
CloudResource mockCreatedContainer = CloudResource.builder()
.resourceId(CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
+ .name(CONTAINER_NAME)
.build();
when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer);
@@ -146,7 +146,7 @@ void createContainer_DbSaveFails_CompensatingTransactionExecuted() {
CloudResource mockCreatedContainer = CloudResource.builder()
.resourceId(CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
+ .name(CONTAINER_NAME)
.build();
when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer);
@@ -179,7 +179,7 @@ void createContainer_CompensationFails_GhostResourceWarningLogged() {
CloudResource mockCreatedContainer = CloudResource.builder()
.resourceId(CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
+ .name(CONTAINER_NAME)
.build();
when(managementPort.createContainer(any())).thenReturn(mockCreatedContainer);
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java
index 286229f5..52983827 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/storage/S3BucketUseCaseServiceTest.java
@@ -115,8 +115,10 @@ void setUp() {
expectedContainer = CloudResource.builder()
.resourceId("container-" + CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
- .displayName("Test Container")
+ .name(CONTAINER_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
}
@@ -135,7 +137,7 @@ void createContainer_Success() {
// Then
assertThat(result).isNotNull();
- assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME);
+ assertThat(result.getName()).isEqualTo(CONTAINER_NAME);
assertThat(result.getResourceId()).isEqualTo("container-" + CONTAINER_NAME);
verify(capabilityGuard).ensureSupported(AWS, "S3", "BUCKET", CapabilityGuard.Operation.TAGGING);
@@ -206,8 +208,10 @@ void setUp() {
expectedContainer = CloudResource.builder()
.resourceId("container-" + CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
- .displayName("Updated Test Container")
+ .name(CONTAINER_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
}
@@ -225,8 +229,7 @@ void updateContainer_Success() {
// Then
assertThat(result).isNotNull();
- assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME);
- assertThat(result.getDisplayName()).isEqualTo("Updated Test Container");
+ assertThat(result.getName()).isEqualTo(CONTAINER_NAME);
verify(accountCredentialManagementPort).getSession(TENANT_KEY, ACCOUNT_SCOPE, AWS);
verify(capabilityGuard).ensureSupported(AWS, "S3", "BUCKET", CapabilityGuard.Operation.TAGGING);
@@ -256,12 +259,18 @@ void setUp() {
CloudResource container1 = CloudResource.builder()
.resourceId("container-1")
- .resourceName("test-container-1")
+ .name("test-container-1")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
CloudResource container2 = CloudResource.builder()
.resourceId("container-2")
- .resourceName("test-container-2")
+ .name("test-container-2")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
expectedPage = new PageImpl<>(List.of(container1, container2), PageRequest.of(0, 10), 2);
@@ -280,7 +289,7 @@ void listContainers_Success() {
assertThat(result).isNotNull();
assertThat(result.getTotalElements()).isEqualTo(2);
assertThat(result.getContent()).hasSize(2);
- assertThat(result.getContent().get(0).getResourceName()).isEqualTo("test-container-1");
+ assertThat(result.getContent().get(0).getName()).isEqualTo("test-container-1");
verify(discoveryPort).listContainers(query);
}
@@ -314,8 +323,10 @@ class GetContainerTest {
void setUp() {
expectedContainer = CloudResource.builder()
.resourceId("container-" + CONTAINER_NAME)
- .resourceName(CONTAINER_NAME)
- .displayName("Test Container")
+ .name(CONTAINER_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("BUCKET")
.build();
}
@@ -330,7 +341,7 @@ void getContainer_Success() {
// Then
assertThat(result).isNotNull();
- assertThat(result.getResourceName()).isEqualTo(CONTAINER_NAME);
+ assertThat(result.getName()).isEqualTo(CONTAINER_NAME);
assertThat(result.getResourceId()).isEqualTo("container-" + CONTAINER_NAME);
verify(discoveryPort).getContainer(ACCOUNT_SCOPE, CONTAINER_NAME);
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java
index f5970b1a..5f837817 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vm/VmUseCaseServiceDbSyncTest.java
@@ -8,7 +8,7 @@
import com.agenticcp.core.domain.cloud.dto.VmDeleteRequest;
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;
+// LifecycleState removed - using JSON status field instead
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;
@@ -122,7 +122,10 @@ void createInstance_Success_SavesCloudResource() {
CloudResource mockCloudResource = CloudResource.builder()
.resourceId(INSTANCE_ID)
- .resourceName("test-instance")
+ .name("test-instance")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
when(vmLifecyclePort.createInstance(any())).thenReturn(INSTANCE_ID);
@@ -139,7 +142,7 @@ void createInstance_Success_SavesCloudResource() {
// Then
assertThat(result).isNotNull();
assertThat(result.getResourceId()).isEqualTo(INSTANCE_ID);
- assertThat(result.getResourceName()).isEqualTo("test-instance");
+ assertThat(result.getName()).isEqualTo("test-instance");
verify(resourceHelper).registerResource(
eq(PROVIDER_TYPE),
@@ -193,7 +196,7 @@ void startInstance_Success_UpdatesLifecycleStateToRunning() {
// Then
verify(vmLifecyclePort).startInstance(eq(INSTANCE_ID), any());
- verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING));
+ verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running"));
}
@Test
@@ -207,7 +210,7 @@ void stopInstance_Success_UpdatesLifecycleStateToStopped() {
// Then
verify(vmLifecyclePort).stopInstance(eq(INSTANCE_ID), any());
- verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.STOPPED));
+ verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("stopped"));
}
@Test
@@ -221,7 +224,7 @@ void rebootInstance_Success_KeepsLifecycleStateRunning() {
// Then
verify(vmLifecyclePort).rebootInstance(eq(INSTANCE_ID), any());
- verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING));
+ verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running"));
}
@Test
@@ -235,7 +238,7 @@ void terminateInstance_Success_UpdatesLifecycleStateToTerminated() {
// Then
verify(vmLifecyclePort).terminateInstance(eq(INSTANCE_ID), any());
- verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.TERMINATED));
+ verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("terminated"));
}
@Test
@@ -250,7 +253,7 @@ void startInstance_ResourceNotInDb_CspOperationSucceeds() {
// Then
verify(vmLifecyclePort).startInstance(eq(INSTANCE_ID), any()); // CSP 작업 성공
- verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq(LifecycleState.RUNNING));
+ verify(resourceHelper).updateLifecycleState(eq(INSTANCE_ID), eq("running"));
}
}
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java
index 1059e29f..07fb101b 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceDbSyncTest.java
@@ -114,7 +114,10 @@ void createVpc_Success_SavesCloudResource() {
CloudResource mockCreatedVpc = CloudResource.builder()
.resourceId(VPC_ID)
- .resourceName(VPC_NAME)
+ .name(VPC_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("VPC")
.build();
when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc);
@@ -147,7 +150,10 @@ void createVpc_DbSaveFails_CompensatingTransactionExecuted() {
CloudResource mockCreatedVpc = CloudResource.builder()
.resourceId(VPC_ID)
- .resourceName(VPC_NAME)
+ .name(VPC_NAME)
+ .provider("AWS")
+ .region("us-east-1")
+ .type("VPC")
.build();
when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc);
@@ -181,7 +187,7 @@ void createVpc_NoVpcName_UsesVpcIdAsResourceName() {
CloudResource mockCreatedVpc = CloudResource.builder()
.resourceId(VPC_ID)
- .resourceName(VPC_ID)
+ .name(VPC_ID)
.build();
when(vpcManagementPort.createVpc(any())).thenReturn(mockCreatedVpc);
diff --git a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java
index 2d395459..0b689b1c 100644
--- a/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/cloud/service/vpc/VpcUseCaseServiceTest.java
@@ -848,9 +848,12 @@ private CloudSessionCredential createMockSession() {
private CloudResource createMockVpcResource() {
CloudResource resource = CloudResource.builder()
.resourceId("vpc-12345678")
- .resourceName("test-vpc")
- .resourceType(CloudResource.ResourceType.NETWORK)
- .lifecycleState(CloudResource.LifecycleState.RUNNING)
+ .name("test-vpc")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("VPC")
+
+
.build();
return resource;
}
diff --git a/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java b/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java
index 210ed1b9..fefc7e70 100644
--- a/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java
+++ b/src/test/java/com/agenticcp/core/domain/platform/service/MultiCloudEnvironmentServiceTest.java
@@ -213,7 +213,7 @@ void detectEnvironment_ResourcesWithoutProvider_IgnoresAndReturnsOnPremise() {
String tenantId = "tenant-no-provider";
CloudResource resourceWithoutProvider = CloudResource.builder()
.resourceId("resource-no-provider")
- .resourceName("Resource Without Provider")
+ .name("Resource Without Provider")
.provider(null) // Provider 없음
.build();
@@ -239,13 +239,15 @@ void detectEnvironment_MixedResources_IgnoresNullProviders() {
CloudResource validResource = CloudResource.builder()
.resourceId("valid-resource")
- .resourceName("Valid Resource")
- .provider(awsProvider)
+ .name("Valid Resource")
+ .provider("AWS")
+ .region("us-east-1")
+ .type("INSTANCE")
.build();
CloudResource invalidResource = CloudResource.builder()
.resourceId("invalid-resource")
- .resourceName("Invalid Resource")
+ .name("Invalid Resource")
.provider(null) // Provider 없음
.build();
@@ -273,8 +275,8 @@ private List createMockResources(CloudProvider.ProviderType... pr
return CloudResource.builder()
.resourceId("resource-" + providerType.name())
- .resourceName(providerType.name() + " Resource")
- .provider(provider)
+ .name(providerType.name() + " Resource")
+ .provider("AWS")
.build();
})
.toList();
diff --git a/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java b/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java
new file mode 100644
index 00000000..ea795aed
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/user/repository/WorkerRepositoryTest.java
@@ -0,0 +1,268 @@
+package com.agenticcp.core.domain.user.repository;
+
+import com.agenticcp.core.domain.organization.entity.Organization;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.User;
+import com.agenticcp.core.domain.user.entity.Worker;
+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.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * WorkerRepository 통합 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@DataJpaTest
+@ActiveProfiles("test")
+@DisplayName("WorkerRepository 통합 테스트")
+class WorkerRepositoryTest {
+
+ @Autowired
+ private TestEntityManager entityManager;
+
+ @Autowired
+ private WorkerRepository workerRepository;
+
+ private User testUser;
+ private Tenant testTenant1;
+ private Tenant testTenant2;
+ private Organization testOrganization;
+ private Worker testWorker1;
+ private Worker testWorker2;
+
+ @BeforeEach
+ void setUp() {
+ // User 생성
+ testUser = User.builder()
+ .username("testuser")
+ .email("test@example.com")
+ .name("Test User")
+ .build();
+ testUser = entityManager.persistAndFlush(testUser);
+
+ // Organization 생성
+ testOrganization = Organization.builder()
+ .name("Test Organization")
+ .build();
+ testOrganization = entityManager.persistAndFlush(testOrganization);
+
+ // Tenant 생성
+ testTenant1 = Tenant.builder()
+ .tenantKey("tenant-1")
+ .tenantName("Tenant 1")
+ .build();
+ testTenant1.setIsDeleted(false);
+ testTenant1 = entityManager.persistAndFlush(testTenant1);
+
+ testTenant2 = Tenant.builder()
+ .tenantKey("tenant-2")
+ .tenantName("Tenant 2")
+ .build();
+ testTenant2.setIsDeleted(false);
+ testTenant2 = entityManager.persistAndFlush(testTenant2);
+
+ // Worker 생성
+ testWorker1 = Worker.builder()
+ .workerKey("worker-1")
+ .user(testUser)
+ .tenant(testTenant1)
+ .organization(testOrganization)
+ .build();
+ testWorker1.setIsDeleted(false);
+ testWorker1 = entityManager.persistAndFlush(testWorker1);
+
+ testWorker2 = Worker.builder()
+ .workerKey("worker-2")
+ .user(testUser)
+ .tenant(testTenant2)
+ .organization(testOrganization)
+ .build();
+ testWorker2.setIsDeleted(false);
+ testWorker2 = entityManager.persistAndFlush(testWorker2);
+ }
+
+ @Nested
+ @DisplayName("findByUserIdAndIsDeletedFalse 테스트")
+ class FindByUserIdTest {
+
+ @Test
+ @DisplayName("User ID로 Worker 목록을 조회할 수 있어야 한다")
+ void findByUserIdAndIsDeletedFalse_WithValidUserId_ReturnsWorkers() {
+ // When
+ List result = workerRepository.findByUserIdAndIsDeletedFalse(testUser.getId());
+
+ // Then
+ assertThat(result).hasSize(2);
+ assertThat(result).extracting(Worker::getWorkerKey)
+ .containsExactlyInAnyOrder("worker-1", "worker-2");
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 User ID로 조회하면 빈 목록을 반환해야 한다")
+ void findByUserIdAndIsDeletedFalse_WithNonExistentUserId_ReturnsEmpty() {
+ // When
+ List result = workerRepository.findByUserIdAndIsDeletedFalse(999L);
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("findByUserIdAndTenantIdAndIsDeletedFalse 테스트")
+ class FindByUserIdAndTenantIdTest {
+
+ @Test
+ @DisplayName("User ID와 Tenant ID로 Worker를 조회할 수 있어야 한다")
+ void findByUserIdAndTenantIdAndIsDeletedFalse_WithValidIds_ReturnsWorker() {
+ // When
+ Optional result = workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(
+ testUser.getId(), testTenant1.getId());
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getWorkerKey()).isEqualTo("worker-1");
+ assertThat(result.get().getTenant().getId()).isEqualTo(testTenant1.getId());
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 조합으로 조회하면 빈 Optional을 반환해야 한다")
+ void findByUserIdAndTenantIdAndIsDeletedFalse_WithNonExistentIds_ReturnsEmpty() {
+ // When
+ Optional result = workerRepository.findByUserIdAndTenantIdAndIsDeletedFalse(
+ 999L, 999L);
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("findByTenantIdAndIsDeletedFalse 테스트")
+ class FindByTenantIdTest {
+
+ @Test
+ @DisplayName("Tenant ID로 Worker 목록을 조회할 수 있어야 한다")
+ void findByTenantIdAndIsDeletedFalse_WithValidTenantId_ReturnsWorkers() {
+ // When
+ List result = workerRepository.findByTenantIdAndIsDeletedFalse(testTenant1.getId());
+
+ // Then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getWorkerKey()).isEqualTo("worker-1");
+ }
+ }
+
+ @Nested
+ @DisplayName("findByWorkerKeyAndIsDeletedFalse 테스트")
+ class FindByWorkerKeyTest {
+
+ @Test
+ @DisplayName("Worker Key로 Worker를 조회할 수 있어야 한다")
+ void findByWorkerKeyAndIsDeletedFalse_WithValidWorkerKey_ReturnsWorker() {
+ // When
+ Optional result = workerRepository.findByWorkerKeyAndIsDeletedFalse("worker-1");
+
+ // Then
+ assertThat(result).isPresent();
+ assertThat(result.get().getWorkerKey()).isEqualTo("worker-1");
+ assertThat(result.get().getUser().getId()).isEqualTo(testUser.getId());
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 Worker Key로 조회하면 빈 Optional을 반환해야 한다")
+ void findByWorkerKeyAndIsDeletedFalse_WithNonExistentWorkerKey_ReturnsEmpty() {
+ // When
+ Optional result = workerRepository.findByWorkerKeyAndIsDeletedFalse("non-existent");
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("findTenantIdsByUserId 테스트")
+ class FindTenantIdsByUserIdTest {
+
+ @Test
+ @DisplayName("User가 속한 모든 Tenant ID 목록을 조회할 수 있어야 한다")
+ void findTenantIdsByUserId_WithValidUserId_ReturnsTenantIds() {
+ // When
+ List result = workerRepository.findTenantIdsByUserId(testUser.getId());
+
+ // Then
+ assertThat(result).hasSize(2);
+ assertThat(result).containsExactlyInAnyOrder(testTenant1.getId(), testTenant2.getId());
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 User ID로 조회하면 빈 목록을 반환해야 한다")
+ void findTenantIdsByUserId_WithNonExistentUserId_ReturnsEmpty() {
+ // When
+ List result = workerRepository.findTenantIdsByUserId(999L);
+
+ // Then
+ assertThat(result).isEmpty();
+ }
+ }
+
+ @Nested
+ @DisplayName("existsByUserIdAndTenantIdAndIsDeletedFalse 테스트")
+ class ExistsByUserIdAndTenantIdTest {
+
+ @Test
+ @DisplayName("User가 Tenant에 속하면 true를 반환해야 한다")
+ void existsByUserIdAndTenantIdAndIsDeletedFalse_WithValidIds_ReturnsTrue() {
+ // When
+ boolean result = workerRepository.existsByUserIdAndTenantIdAndIsDeletedFalse(
+ testUser.getId(), testTenant1.getId());
+
+ // Then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("User가 Tenant에 속하지 않으면 false를 반환해야 한다")
+ void existsByUserIdAndTenantIdAndIsDeletedFalse_WithInvalidIds_ReturnsFalse() {
+ // When
+ boolean result = workerRepository.existsByUserIdAndTenantIdAndIsDeletedFalse(
+ 999L, 999L);
+
+ // Then
+ assertThat(result).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("삭제된 Worker 필터링 테스트")
+ class DeletedWorkerTest {
+
+ @Test
+ @DisplayName("삭제된 Worker는 조회되지 않아야 한다")
+ void findByUserIdAndIsDeletedFalse_WithDeletedWorker_ExcludesDeleted() {
+ // Given
+ testWorker1.setIsDeleted(true);
+ entityManager.persistAndFlush(testWorker1);
+
+ // When
+ List result = workerRepository.findByUserIdAndIsDeletedFalse(testUser.getId());
+
+ // Then
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getWorkerKey()).isEqualTo("worker-2");
+ }
+ }
+}
+
diff --git a/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java b/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java
new file mode 100644
index 00000000..a2c5174c
--- /dev/null
+++ b/src/test/java/com/agenticcp/core/domain/user/service/PermissionServiceCanTest.java
@@ -0,0 +1,311 @@
+package com.agenticcp.core.domain.user.service;
+
+import com.agenticcp.core.common.context.TenantContextHolder;
+import com.agenticcp.core.domain.cloud.entity.CloudResource;
+import com.agenticcp.core.domain.tenant.entity.Tenant;
+import com.agenticcp.core.domain.user.entity.Permission;
+import com.agenticcp.core.domain.user.entity.Role;
+import com.agenticcp.core.domain.user.entity.User;
+import com.agenticcp.core.domain.user.entity.Worker;
+import com.agenticcp.core.domain.user.entity.WorkerRoleAssignment;
+import com.agenticcp.core.domain.user.repository.PermissionRepository;
+import com.agenticcp.core.domain.user.repository.RoleRepository;
+import com.agenticcp.core.domain.user.repository.WorkerRoleAssignmentRepository;
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.*;
+
+/**
+ * PermissionService.can() 메서드 단위 테스트
+ *
+ * @author AgenticCP Team
+ * @version 1.0.0
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("PermissionService.can() 메서드 테스트")
+class PermissionServiceCanTest {
+
+ @Mock
+ private PermissionRepository permissionRepository;
+
+ @Mock
+ private RoleRepository roleRepository;
+
+ @Mock
+ private WorkerRoleAssignmentRepository workerRoleAssignmentRepository;
+
+ @InjectMocks
+ private PermissionService permissionService;
+
+ private Tenant testTenant;
+ private User testUser;
+ private Worker testWorker;
+ private Role testRole;
+ private Permission testPermission;
+ private CloudResource testResource;
+
+ @BeforeEach
+ void setUp() {
+ testTenant = Tenant.builder()
+ .tenantKey("tenant-1")
+ .tenantName("Tenant 1")
+ .build();
+ testTenant.setId(100L);
+ testTenant.setIsDeleted(false);
+
+ testUser = User.builder()
+ .username("testuser")
+ .build();
+ testUser.setId(1L);
+
+ testWorker = Worker.builder()
+ .workerKey("worker-1")
+ .user(testUser)
+ .tenant(testTenant)
+ .build();
+ testWorker.setId(10L);
+ testWorker.setIsDeleted(false);
+
+ testPermission = Permission.builder()
+ .permissionKey("vm:start")
+ .permissionName("VM Start")
+ .resource("INSTANCE")
+ .action("START")
+ .tenant(testTenant)
+ .build();
+ testPermission.setId(1000L);
+
+ testRole = Role.builder()
+ .roleKey("vm-admin")
+ .roleName("VM Admin")
+ .permissions(List.of(testPermission))
+ .tenant(testTenant)
+ .build();
+ testRole.setId(100L);
+
+ testResource = CloudResource.builder()
+ .resourceId("vm-001")
+ .name("Test VM")
+ .type("INSTANCE")
+ .provider("AWS")
+ .region("us-east-1")
+ .tenant(testTenant)
+ .build();
+ testResource.setId(1L);
+ }
+
+ @AfterEach
+ void tearDown() {
+ TenantContextHolder.clear();
+ }
+
+ @Nested
+ @DisplayName("권한 체크 성공 테스트")
+ class PermissionCheckSuccessTest {
+
+ @Test
+ @DisplayName("Worker가 리소스에 대한 권한이 있으면 true를 반환해야 한다")
+ void can_WithValidPermission_ReturnsTrue() {
+ // Given
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(100L));
+ when(roleRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(testRole));
+ when(permissionRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(testPermission));
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", testResource);
+
+ // Then
+ assertThat(result).isTrue();
+ verify(workerRoleAssignmentRepository).findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class));
+ verify(roleRepository).findAllById(anyList());
+ verify(permissionRepository).findAllById(anyList());
+ }
+
+ @Test
+ @DisplayName("TenantContextHolder에서 workerId와 tenantId를 가져와서 권한 체크를 수행할 수 있어야 한다")
+ void can_WithContextHolder_ReturnsTrue() {
+ // Given
+ TenantContextHolder.setCurrentTenantAndWorker(testTenant, testWorker);
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(100L));
+ when(roleRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(testRole));
+ when(permissionRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(testPermission));
+
+ // When
+ boolean result = permissionService.can(null, null, "START", testResource);
+
+ // Then
+ assertThat(result).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("Tenant 격리 실패 테스트")
+ class TenantIsolationFailureTest {
+
+ @Test
+ @DisplayName("리소스의 Tenant가 현재 Tenant와 다르면 false를 반환해야 한다")
+ void can_WithDifferentTenant_ReturnsFalse() {
+ // Given
+ Tenant otherTenant = Tenant.builder()
+ .tenantKey("tenant-2")
+ .build();
+ otherTenant.setId(200L);
+ CloudResource otherTenantResource = CloudResource.builder()
+ .resourceId("vm-002")
+ .type("INSTANCE")
+ .tenant(otherTenant)
+ .build();
+ otherTenantResource.setId(2L);
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", otherTenantResource);
+
+ // Then
+ assertThat(result).isFalse();
+ verify(workerRoleAssignmentRepository, never()).findRoleIdsByTenantIdAndWorkerId(anyLong(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("리소스에 Tenant가 없으면 false를 반환해야 한다")
+ void can_WithNullTenant_ReturnsFalse() {
+ // Given
+ CloudResource resourceWithoutTenant = CloudResource.builder()
+ .resourceId("vm-003")
+ .type("INSTANCE")
+ .tenant(null)
+ .build();
+ resourceWithoutTenant.setId(3L);
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", resourceWithoutTenant);
+
+ // Then
+ assertThat(result).isFalse();
+ verify(workerRoleAssignmentRepository, never()).findRoleIdsByTenantIdAndWorkerId(anyLong(), anyLong(), any());
+ }
+ }
+
+ @Nested
+ @DisplayName("Role 없음 테스트")
+ class NoRoleTest {
+
+ @Test
+ @DisplayName("Worker에게 할당된 Role이 없으면 false를 반환해야 한다")
+ void can_WithNoRoles_ReturnsFalse() {
+ // Given
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Collections.emptyList());
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", testResource);
+
+ // Then
+ assertThat(result).isFalse();
+ verify(workerRoleAssignmentRepository).findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class));
+ verify(roleRepository, never()).findAllById(anyList());
+ }
+ }
+
+ @Nested
+ @DisplayName("Permission 없음 테스트")
+ class NoPermissionTest {
+
+ @Test
+ @DisplayName("Role에 Permission이 없으면 false를 반환해야 한다")
+ void can_WithNoPermissions_ReturnsFalse() {
+ // Given
+ Role roleWithoutPermission = Role.builder()
+ .roleKey("vm-admin")
+ .permissions(Collections.emptyList())
+ .build();
+ roleWithoutPermission.setId(100L);
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(100L));
+ when(roleRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(roleWithoutPermission));
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", testResource);
+
+ // Then
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("Permission이 리소스 타입과 일치하지 않으면 false를 반환해야 한다")
+ void can_WithMismatchedResourceType_ReturnsFalse() {
+ // Given
+ Permission differentResourcePermission = Permission.builder()
+ .resource("BUCKET")
+ .action("START")
+ .build();
+ differentResourcePermission.setId(1001L);
+ Role roleWithDifferentPermission = Role.builder()
+ .permissions(List.of(differentResourcePermission))
+ .build();
+ roleWithDifferentPermission.setId(100L);
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(100L));
+ when(roleRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(roleWithDifferentPermission));
+ when(permissionRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(differentResourcePermission));
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", testResource);
+
+ // Then
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("Permission이 액션과 일치하지 않으면 false를 반환해야 한다")
+ void can_WithMismatchedAction_ReturnsFalse() {
+ // Given
+ Permission differentActionPermission = Permission.builder()
+ .resource("INSTANCE")
+ .action("STOP")
+ .build();
+ differentActionPermission.setId(1002L);
+ Role roleWithDifferentAction = Role.builder()
+ .permissions(List.of(differentActionPermission))
+ .build();
+ roleWithDifferentAction.setId(100L);
+ when(workerRoleAssignmentRepository.findRoleIdsByTenantIdAndWorkerId(100L, 10L, any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(100L));
+ when(roleRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(roleWithDifferentAction));
+ when(permissionRepository.findAllById(anyList()))
+ .thenReturn(Arrays.asList(differentActionPermission));
+
+ // When
+ boolean result = permissionService.can(10L, 100L, "START", testResource);
+
+ // Then
+ assertThat(result).isFalse();
+ }
+ }
+}
+