Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum ProjectErrorCode implements ErrorCode {

// 400 Bad Request
INVALID_REQUEST_PARAMETER(400, "ACC-PROJECT-INVALID-REQUEST-PARAM", "필수 요청 파라미터가 누락되었거나 유효하지 않습니다."),
INVALID_PROJECT_NAME(400, "ACC-PROJECT-INVALID-PROJECT-NAME", "프로젝트명은 영어 소문자, 숫자, '-'만 사용할 수 있습니다."),
INVALID_PROJECT_STATUS(400, "ACC-PROJECT-INVALID-PROJECT-STATUS", "잘못된 프로젝트 상태 값입니다."),
INVALID_PROJECT_REQUEST_TYPE(400, "ACC-PROJECT-INVALID-PROJECT-REQUEST-TYPE", "올바르지 않은 프로젝트 요청타입 입니다."),
INVALID_PROJECT_REQUEST_STATUS(400, "ACC-PROJECT-INVALID-PROJECT-REQUEST-STATUS", "올바르지 않은 프로젝트 요청상태 입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ ResponseEntity<PageResponse<ProjectRequestResponse>> getProjectRequests(
name = "프로젝트 생성요청 등록 성공",
value = "{ \n"
+ " \"projectRequestId\": \"f01213b2-6900-4086-b3fa-a1bbdd36765c\",\n"
+ " \"projectName\": \"가나다라마바사\",\n"
+ " \"projectName\": \"capstone-project\",\n"
+ " \"projectType\": \"PROJECT_REQUEST_TYPE/MAJOR_LECTURE\",\n"
+ " \"createdAt\": \"2025-11-25T02:14:58.849064\",\n"
+ " \"status\": \"PENDING\",\n"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import io.swagger.v3.oas.annotations.media.Schema;

public record CreateProjectRequest(
@Schema(description = "프로젝트 이름", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "프로젝트 이름 (영어 소문자, 숫자, '-'만 허용)", requiredMode = Schema.RequiredMode.REQUIRED)
String projectName,

@Schema(description = "프로젝트 설명", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import io.swagger.v3.oas.annotations.media.Schema;

public record CreateProjectRequestRequest(
@Schema(description = "프로젝트 이름") String projectName,
@Schema(description = "프로젝트 이름 (영어 소문자, 숫자, '-'만 허용)") String projectName,
@Schema(description = "프로젝트 유형") ProjectRequestType projectType,
@Schema(description = "프로젝트 설명 (최대 10,000자)") String projectDescription
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.acc.local.external.dto.keystone.KeystoneProject;
import com.acc.local.service.modules.auth.AuthModule;
import com.acc.local.service.modules.auth.KeystoneTokenModule;
import com.acc.local.service.modules.auth.ProjectNameValidator;
import com.acc.local.service.modules.auth.ProjectModule;
import com.acc.local.service.modules.network.NeutronModule;
import com.acc.local.service.modules.outbox.ProjectCreatedEvent;
Expand Down Expand Up @@ -51,6 +52,7 @@ public class AdminProjectServiceAdapter implements AdminProjectServicePort {
@Transactional
public CreateProjectResponse createProject(CreateProjectRequest createProjectRequest, String sessionId) {
String userId = sessionModule.getKeystoneUserId(sessionId);
ProjectNameValidator.validate(createProjectRequest.projectName());
// TODO: userId를 통해, 요청을 보낸 사람이 Root인지 권한 확인
String adminToken = authModule.issueSystemAdminTokenWithAdminProjectScope(userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public class ProjectModule {

// ============ Project Request ============
public CreateProjectRequestResponse createProjectRequest(CreateProjectRequestRequest request, String requestUserId) {
ProjectNameValidator.validate(request.projectName());
checkIdenticalProjectRequestExist(request, requestUserId);

ProjectRequestEntity newRequest = ProjectRequestEntity.builder()
Expand Down Expand Up @@ -261,6 +262,8 @@ private ProjectEntity getDatabaseProject(String projectId) {
}

public KeystoneProject createProject(String adminToken, ProjectCreateDto request, String commandUserId) {
ProjectNameValidator.validate(request.projectName());
Comment thread
jjjjjk12 marked this conversation as resolved.

CreateKeystoneProjectRequest project = CreateKeystoneProjectRequest.builder()
.projectName(request.projectName())
.projectDescription(request.projectDescription())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.acc.local.service.modules.auth;

import java.util.regex.Pattern;

import com.acc.global.exception.project.ProjectErrorCode;
import com.acc.global.exception.project.ProjectServiceException;

public final class ProjectNameValidator {

private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile("^[a-z0-9-]+$");

private ProjectNameValidator() {
}

public static void validate(String projectName) {
if (projectName == null || projectName.isBlank() || !PROJECT_NAME_PATTERN.matcher(projectName).matches()) {
throw new ProjectServiceException(ProjectErrorCode.INVALID_PROJECT_NAME);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import com.acc.local.domain.enums.project.ProjectRequestType;
import com.acc.global.common.PageRequest;
import com.acc.global.common.PageResponse;
import com.acc.global.exception.project.ProjectErrorCode;
import com.acc.global.exception.project.ProjectServiceException;
import com.acc.local.dto.auth.UserKeystoneDto;
import com.acc.local.dto.project.CreateProjectRequest;
import com.acc.local.dto.project.ProjectCreateDto;
import com.acc.local.dto.project.DecideProjectRequestResponse;
import com.acc.local.dto.project.ProjectListServiceDto;
Expand All @@ -14,6 +17,7 @@
import com.acc.local.dto.project.RepositoryPagination;
import com.acc.local.dto.project.ProjectServiceDto;
import com.acc.local.dto.project.quota.ProjectGlobalQuotaDto;
import com.acc.local.dto.project.quota.ProjectQuotaRequest;
import com.acc.local.external.dto.keystone.KeystoneProject;
import com.acc.local.service.modules.auth.AuthModule;
import com.acc.local.service.modules.auth.KeystoneTokenModule;
Expand All @@ -32,6 +36,7 @@
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
Expand Down Expand Up @@ -65,6 +70,33 @@ class AdminProjectServiceAdapterTest {
@InjectMocks
private AdminProjectServiceAdapter adminProjectServiceAdapter;

@Test
@DisplayName("관리자 프로젝트 직접 생성 시 허용되지 않는 프로젝트명은 관리자 토큰 발급 전에 거부한다.")
void givenInvalidProjectName_whenCreateProject_thenThrowBeforeAdminTokenIssue() {
String sessionId = "session-id";
String adminUserId = "admin-user-id";
CreateProjectRequest request = new CreateProjectRequest(
"Invalid_Project",
"description",
ProjectRequestType.ETC,
ProjectQuotaRequest.builder()
.vCpu(1)
.vRam(1024)
.storage(10)
.instance(1)
.build(),
"owner-user-id"
);

given(sessionModule.getKeystoneUserId(sessionId)).willReturn(adminUserId);

assertThatThrownBy(() -> adminProjectServiceAdapter.createProject(request, sessionId))
.isInstanceOfSatisfying(ProjectServiceException.class, exception ->
assertThat(exception.getErrorCode()).isEqualTo(ProjectErrorCode.INVALID_PROJECT_NAME));
then(authModule).should(never()).issueSystemAdminTokenWithAdminProjectScope(anyString());
then(projectModule).shouldHaveNoInteractions();
}

@Test
@DisplayName("관리자 프로젝트 요청 목록은 모듈 pagination 정보를 API 응답으로 보존한다.")
void givenProjectRequestPagination_whenGetProjectRequests_thenReturnMarkers() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package com.acc.local.service.modules.auth;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;

import com.acc.global.common.PageRequest;
import com.acc.global.exception.project.ProjectErrorCode;
import com.acc.global.exception.project.ProjectServiceException;
import com.acc.local.domain.enums.project.ProjectRequestStatus;
import com.acc.local.domain.enums.project.ProjectRequestType;
import com.acc.local.dto.project.CreateProjectRequestRequest;
import com.acc.local.dto.project.CreateProjectRequestResponse;
import com.acc.local.dto.project.ProjectCreateDto;
import com.acc.local.dto.project.ProjectRequestDto;
import com.acc.local.dto.project.ProjectRequestListServiceDto;
Expand Down Expand Up @@ -71,6 +78,42 @@ class ProjectModuleTest {
@InjectMocks
private ProjectModule projectModule;

@Test
@DisplayName("프로젝트 요청 생성 시 영어 소문자, 숫자, 하이픈으로 구성된 프로젝트명은 저장한다.")
void givenValidProjectName_whenCreateProjectRequest_thenSaveRequest() {
CreateProjectRequestRequest request = new CreateProjectRequestRequest(
"valid-project-123",
ProjectRequestType.ETC,
"description"
);
given(projectRequestRepositoryPort.findAllByKeyword("valid-project-123", "owner-id"))
.willReturn(List.of());
given(projectRequestRepositoryPort.save(any(ProjectRequestEntity.class)))
.willAnswer(invocation -> persistedEntity(invocation.getArgument(0)));

CreateProjectRequestResponse response = projectModule.createProjectRequest(request, "owner-id");

assertThat(response.projectName()).isEqualTo("valid-project-123");
ArgumentCaptor<ProjectRequestEntity> requestCaptor = ArgumentCaptor.forClass(ProjectRequestEntity.class);
then(projectRequestRepositoryPort).should().save(requestCaptor.capture());
assertThat(requestCaptor.getValue().getProjectName()).isEqualTo("valid-project-123");
}

@Test
@DisplayName("프로젝트 요청 생성 시 허용되지 않는 프로젝트명은 저장 전에 거부한다.")
void givenInvalidProjectName_whenCreateProjectRequest_thenThrowBeforeSave() {
CreateProjectRequestRequest request = new CreateProjectRequestRequest(
"Invalid_Project",
ProjectRequestType.ETC,
"description"
);

assertThatThrownBy(() -> projectModule.createProjectRequest(request, "owner-id"))
.isInstanceOfSatisfying(ProjectServiceException.class, exception ->
assertThat(exception.getErrorCode()).isEqualTo(ProjectErrorCode.INVALID_PROJECT_NAME));
then(projectRequestRepositoryPort).shouldHaveNoInteractions();
}

@Test
@DisplayName("프로젝트 요청 목록은 다음 데이터가 있을 때 현재 페이지 마지막 ID를 nextMarker로 반환한다.")
void givenFullPageAndNextData_whenGetProjectRequestList_thenReturnNextMarker() {
Expand Down Expand Up @@ -188,6 +231,29 @@ void givenProjectCreateDtoWithProjectType_whenCreateProject_thenSaveProjectType(
assertThat(projectCaptor.getValue().getProjectType()).isEqualTo(ProjectRequestType.MAJOR_LECTURE);
}

@Test
@DisplayName("프로젝트 생성 시 허용되지 않는 프로젝트명은 Keystone 호출 전에 거부한다.")
void givenInvalidProjectName_whenCreateProject_thenThrowBeforeExternalCall() {
ProjectCreateDto request = ProjectCreateDto.builder()
.projectName("Invalid_Project")
.projectDescription("description")
.projectType(ProjectRequestType.MAJOR_LECTURE)
.projectOwnerId("owner-user-id")
.quota(ProjectQuotaRequest.builder()
.vCpu(4)
.vRam(8192)
.storage(100)
.instance(2)
.build())
.build();

assertThatThrownBy(() -> projectModule.createProject("admin-token", request, "admin-user-id"))
.isInstanceOfSatisfying(ProjectServiceException.class, exception ->
assertThat(exception.getErrorCode()).isEqualTo(ProjectErrorCode.INVALID_PROJECT_NAME));
then(keystoneAPIExternalPort).should(never()).createProject(anyString(), any(CreateKeystoneProjectRequest.class));
then(projectRepositoryPort).shouldHaveNoInteractions();
}

private ProjectRequestEntity entity(String id) {
return ProjectRequestEntity.builder()
.projectRequestId(id)
Expand All @@ -200,4 +266,17 @@ private ProjectRequestEntity entity(String id) {
.updatedAt(LocalDateTime.now())
.build();
}

private ProjectRequestEntity persistedEntity(ProjectRequestEntity entity) {
return ProjectRequestEntity.builder()
.projectRequestId(entity.getProjectRequestId())
.requestUserId(entity.getRequestUserId())
.projectName(entity.getProjectName())
.projectType(entity.getProjectType())
.status(entity.getStatus())
.projectDescription(entity.getProjectDescription())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
}