From f4f75762152bb9428dd6a5615726a8563afdfde1 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:27:51 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[FEAT]=20authorization=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reservation/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/reservation/build.gradle b/reservation/build.gradle index 4c843bd8..cbaf6ef9 100644 --- a/reservation/build.gradle +++ b/reservation/build.gradle @@ -40,6 +40,8 @@ jacocoTestReport { } dependencies { + // === 인증 모듈 추가 === + implementation project(':authorization-shared') implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From 1a7c47f2e0b7bee95c65d3d781293467bb87215c Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:29:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[FEAT]=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/domain/error/DomainErrorCode.java | 31 ++++++++++ .../shared/domain/error/DomainException.java | 49 ++++++++++++++++ .../domain/error/DomainErrorCodeTest.java | 38 ++++++++++++ .../domain/error/DomainExceptionTest.java | 58 +++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCode.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainException.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCodeTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainExceptionTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCode.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCode.java new file mode 100644 index 00000000..d67ebca5 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCode.java @@ -0,0 +1,31 @@ +package net.catsnap.CatsnapReservation.shared.domain.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +/** + * 도메인 에러 코드 + *

+ * 비즈니스 규칙 위반 시 발생하는 에러를 정의합니다. + * 예약 중복, 불가능한 시간대, 이미 취소된 예약 등 도메인 로직 관련 에러를 담당합니다. + *

+ */ +@Getter +@RequiredArgsConstructor +public enum DomainErrorCode implements ResultCode { + + /** + * 도메인 제약 조건 위반 + *

+ * 도메인 로직 상 허용되지 않는 값이나 상태인 경우 + *

+ */ + DOMAIN_CONSTRAINT_VIOLATION(HttpStatus.BAD_REQUEST, "ED000", "해당 값이 유효하지 않습니다."); + + private final HttpStatusCode httpStatus; + private final String code; + private final String message; +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainException.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainException.java new file mode 100644 index 00000000..036072df --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainException.java @@ -0,0 +1,49 @@ +package net.catsnap.CatsnapReservation.shared.domain.error; + +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.ResultCode; + +/** + * 도메인 예외 최상위 클래스 + *

+ * 도메인 레이어에서 비즈니스 규칙 위반 시 발생하는 모든 예외의 기본 클래스입니다. + * ResultCode를 포함하여 일관된 응답 형식을 제공합니다. + *

+ */ +@Getter +public class DomainException extends RuntimeException { + + private final ResultCode resultCode; + + /** + * ResultCode를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + */ + public DomainException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + /** + * ResultCode와 원인 예외를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + * @param cause 원인 예외 + */ + public DomainException(ResultCode resultCode, Throwable cause) { + super(resultCode.getMessage(), cause); + this.resultCode = resultCode; + } + + /** + * ResultCode와 추가 메시지를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + * @param additionalMessage 추가 메시지 + */ + public DomainException(ResultCode resultCode, String additionalMessage) { + super(resultCode.getMessage() + " - " + additionalMessage); + this.resultCode = resultCode; + } +} \ No newline at end of file diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCodeTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCodeTest.java new file mode 100644 index 00000000..b85e8bd8 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainErrorCodeTest.java @@ -0,0 +1,38 @@ +package net.catsnap.CatsnapReservation.shared.domain.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("DomainErrorCode 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DomainErrorCodeTest { + + @Test + void 모든_DomainErrorCode는_ED로_시작하는_코드를_가진다() { + // when & then + for (DomainErrorCode errorCode : DomainErrorCode.values()) { + assertThat(errorCode.getCode()).startsWith("ED"); + } + } + + @Test + void 모든_DomainErrorCode는_HttpStatus를_가진다() { + // when & then + for (DomainErrorCode errorCode : DomainErrorCode.values()) { + assertThat(errorCode.getHttpStatus()).isNotNull(); + } + } + + @Test + void 모든_DomainErrorCode는_메시지를_가진다() { + // when & then + for (DomainErrorCode errorCode : DomainErrorCode.values()) { + assertThat(errorCode.getMessage()).isNotBlank(); + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainExceptionTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainExceptionTest.java new file mode 100644 index 00000000..fd2595aa --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/domain/error/DomainExceptionTest.java @@ -0,0 +1,58 @@ +package net.catsnap.CatsnapReservation.shared.domain.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("DomainException 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DomainExceptionTest { + + @Test + void ResultCode로_예외를_생성한다() { + // given + ResultCode resultCode = DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION; + + // when + DomainException exception = new DomainException(resultCode); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("해당 값이 유효하지 않습니다."); + } + + @Test + void ResultCode와_원인_예외로_예외를_생성한다() { + // given + ResultCode resultCode = DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION; + Throwable cause = new RuntimeException("원인 예외"); + + // when + DomainException exception = new DomainException(resultCode, cause); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("해당 값이 유효하지 않습니다."); + assertThat(exception.getCause()).isEqualTo(cause); + } + + @Test + void ResultCode와_추가_메시지로_예외를_생성한다() { + // given + ResultCode resultCode = DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION; + String additionalMessage = "상세 정보"; + + // when + DomainException exception = new DomainException(resultCode, additionalMessage); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).contains("해당 값이 유효하지 않습니다."); + assertThat(exception.getMessage()).contains("상세 정보"); + } +} From 5766ccff41dec92f90b6e0b5f539dac389c32612 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:30:39 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[FEAT]=20presentation=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../error/PresentationErrorCode.java | 102 ++++++++++++++++++ .../error/PresentationException.java | 50 +++++++++ .../error/PresentationErrorCodeTest.java | 60 +++++++++++ .../error/PresentationExceptionTest.java | 84 +++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCode.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationException.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCodeTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationExceptionTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCode.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCode.java new file mode 100644 index 00000000..e7c4ecaf --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCode.java @@ -0,0 +1,102 @@ +package net.catsnap.CatsnapReservation.shared.presentation.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +/** + * 프레젠테이션 레이어 에러 코드 + *

+ * API 요청/응답 처리 과정에서 발생하는 에러를 정의합니다. + * 잘못된 요청 형식, 존재하지 않는 엔드포인트, 인증/인가 실패 등 HTTP 레벨의 에러를 담당합니다. + *

+ */ +@Getter +@RequiredArgsConstructor +public enum PresentationErrorCode implements ResultCode { + + // ========== API 요청 관련 에러 (EA0XX) ========== + + /** + * 존재하지 않는 API 엔드포인트 + */ + NOT_FOUND_API(HttpStatus.NOT_FOUND, "EA000", "존재하지 않는 API입니다."), + + /** + * 필수 요청 파라미터 누락 + */ + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "EA001", "필수 요청 파라미터가 누락되었습니다."), + + /** + * 잘못된 요청 바디 + */ + INVALID_REQUEST_BODY(HttpStatus.BAD_REQUEST, "EA002", "요청 바디가 올바르지 않습니다."), + + /** + * 서버 내부 오류 + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "EA003", "서버 내부 오류가 발생했습니다."), + + /** + * 잘못된 요청 형식 + */ + INVALID_REQUEST_FORMAT(HttpStatus.BAD_REQUEST, "EA004", "요청 형식이 올바르지 않습니다."), + + /** + * 메서드 지원 안 함 + */ + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "EA005", "지원하지 않는 HTTP 메서드입니다."), + + /** + * 미지원 미디어 타입 + */ + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "EA006", "지원하지 않는 미디어 타입입니다."), + + // ========== 인증/인가 관련 에러 (EA1XX) ========== + + /** + * 인증 정보 없음 (401 Unauthorized) + *

+ * 요청에 인증 헤더가 없거나 비어있는 경우 + *

+ */ + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "EA100", "인증 정보가 없습니다."), + + /** + * 유효하지 않은 권한 정보 (401 Unauthorized) + *

+ * 인증 헤더의 권한 값이 유효하지 않은 경우 + *

+ */ + INVALID_AUTHORITY(HttpStatus.UNAUTHORIZED, "EA101", "유효하지 않은 권한 정보입니다."), + + /** + * 접근 권한 없음 (403 Forbidden) + *

+ * 인증은 되었으나 해당 리소스에 접근할 권한이 없는 경우 + *

+ */ + FORBIDDEN(HttpStatus.FORBIDDEN, "EA102", "접근 권한이 없습니다."), + + /** + * 유효하지 않은 Passport (401 Unauthorized) + *

+ * Passport 서명 검증에 실패했거나 파싱할 수 없는 경우 + *

+ */ + INVALID_PASSPORT(HttpStatus.UNAUTHORIZED, "EA103", "유효하지 않은 Passport입니다."), + + /** + * 만료된 Passport (401 Unauthorized) + *

+ * Passport의 유효기간이 만료된 경우 + *

+ */ + EXPIRED_PASSPORT(HttpStatus.UNAUTHORIZED, "EA104", "만료된 Passport입니다."); + + private final HttpStatusCode httpStatus; + private final String code; + private final String message; +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationException.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationException.java new file mode 100644 index 00000000..bc3034ed --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationException.java @@ -0,0 +1,50 @@ +package net.catsnap.CatsnapReservation.shared.presentation.error; + +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.ResultCode; + +/** + * 프레젠테이션 예외 최상위 클래스 + *

+ * 프레젠테이션 레이어에서 발생하는 모든 예외의 기본 클래스입니다. + * API 요청 형식 오류, 인증/인가 실패 등의 예외를 포함합니다. + * ResultCode를 포함하여 일관된 응답 형식을 제공합니다. + *

+ */ +@Getter +public class PresentationException extends RuntimeException { + + private final ResultCode resultCode; + + /** + * ResultCode를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + */ + public PresentationException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + /** + * ResultCode와 원인 예외를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + * @param cause 원인 예외 + */ + public PresentationException(ResultCode resultCode, Throwable cause) { + super(resultCode.getMessage(), cause); + this.resultCode = resultCode; + } + + /** + * ResultCode와 추가 메시지를 포함한 비즈니스 예외를 생성합니다. + * + * @param resultCode 응답에 사용할 결과 코드 + * @param additionalMessage 추가 메시지 + */ + public PresentationException(ResultCode resultCode, String additionalMessage) { + super(resultCode.getMessage() + " - " + additionalMessage); + this.resultCode = resultCode; + } +} \ No newline at end of file diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCodeTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCodeTest.java new file mode 100644 index 00000000..0fcc93af --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationErrorCodeTest.java @@ -0,0 +1,60 @@ +package net.catsnap.CatsnapReservation.shared.presentation.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("PresentationErrorCode 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PresentationErrorCodeTest { + + @Test + void 모든_PresentationErrorCode는_EA로_시작하는_코드를_가진다() { + // when & then + for (PresentationErrorCode errorCode : PresentationErrorCode.values()) { + assertThat(errorCode.getCode()).startsWith("EA"); + } + } + + @Test + void 모든_PresentationErrorCode는_HttpStatus를_가진다() { + // when & then + for (PresentationErrorCode errorCode : PresentationErrorCode.values()) { + assertThat(errorCode.getHttpStatus()).isNotNull(); + } + } + + @Test + void 모든_PresentationErrorCode는_메시지를_가진다() { + // when & then + for (PresentationErrorCode errorCode : PresentationErrorCode.values()) { + assertThat(errorCode.getMessage()).isNotBlank(); + } + } + + @Test + void API_요청_에러는_EA0XX_코드를_가진다() { + // when & then + assertThat(PresentationErrorCode.NOT_FOUND_API.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.MISSING_REQUEST_PARAMETER.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.INVALID_REQUEST_BODY.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.INTERNAL_SERVER_ERROR.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.INVALID_REQUEST_FORMAT.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.METHOD_NOT_ALLOWED.getCode()).startsWith("EA0"); + assertThat(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.getCode()).startsWith("EA0"); + } + + @Test + void 인증_인가_에러는_EA1XX_코드를_가진다() { + // when & then + assertThat(PresentationErrorCode.UNAUTHORIZED.getCode()).startsWith("EA1"); + assertThat(PresentationErrorCode.INVALID_AUTHORITY.getCode()).startsWith("EA1"); + assertThat(PresentationErrorCode.FORBIDDEN.getCode()).startsWith("EA1"); + assertThat(PresentationErrorCode.INVALID_PASSPORT.getCode()).startsWith("EA1"); + assertThat(PresentationErrorCode.EXPIRED_PASSPORT.getCode()).startsWith("EA1"); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationExceptionTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationExceptionTest.java new file mode 100644 index 00000000..4e55f300 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/error/PresentationExceptionTest.java @@ -0,0 +1,84 @@ +package net.catsnap.CatsnapReservation.shared.presentation.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("PresentationException 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PresentationExceptionTest { + + @Test + void ResultCode로_예외를_생성한다() { + // given + ResultCode resultCode = PresentationErrorCode.INTERNAL_SERVER_ERROR; + + // when + PresentationException exception = new PresentationException(resultCode); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("서버 내부 오류가 발생했습니다."); + } + + @Test + void ResultCode와_원인_예외로_예외를_생성한다() { + // given + ResultCode resultCode = PresentationErrorCode.INTERNAL_SERVER_ERROR; + Throwable cause = new RuntimeException("원인 예외"); + + // when + PresentationException exception = new PresentationException(resultCode, cause); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("서버 내부 오류가 발생했습니다."); + assertThat(exception.getCause()).isEqualTo(cause); + } + + @Test + void ResultCode와_추가_메시지로_예외를_생성한다() { + // given + ResultCode resultCode = PresentationErrorCode.INTERNAL_SERVER_ERROR; + String additionalMessage = "상세 정보"; + + // when + PresentationException exception = new PresentationException(resultCode, additionalMessage); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).contains("서버 내부 오류가 발생했습니다."); + assertThat(exception.getMessage()).contains("상세 정보"); + } + + @Test + void 인증_에러코드로_예외를_생성한다() { + // given + ResultCode resultCode = PresentationErrorCode.UNAUTHORIZED; + + // when + PresentationException exception = new PresentationException(resultCode); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("인증 정보가 없습니다."); + } + + @Test + void 인가_에러코드로_예외를_생성한다() { + // given + ResultCode resultCode = PresentationErrorCode.FORBIDDEN; + + // when + PresentationException exception = new PresentationException(resultCode); + + // then + assertThat(exception.getResultCode()).isEqualTo(resultCode); + assertThat(exception.getMessage()).isEqualTo("접근 권한이 없습니다."); + } +} From 749a4c8fda29fd81b06d63ca742c3dbe65f0bd2c Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:31:18 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[FEAT]=20PassportHandler=20=EB=B9=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/config/PassportConfig.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/PassportConfig.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/PassportConfig.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/PassportConfig.java new file mode 100644 index 00000000..3477bbd2 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/PassportConfig.java @@ -0,0 +1,24 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.config; + +import net.catsnap.shared.passport.domain.PassportHandler; +import net.catsnap.shared.passport.infrastructure.BinaryPassportHandler; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PassportConfig { + + @Value("${passport.secret-key}") + private String secretKeyString; + + /** + * PassportHandler 빈을 생성합니다. BinaryPassportHandler 구현체를 사용하여 바이트 기반 서명된 Passport를 발급하고 파싱합니다. + * + * @return PassportHandler 구현체 + */ + @Bean + public PassportHandler passportHandler() { + return new BinaryPassportHandler(secretKeyString); + } +} From 0036ebb95ed59951db6b5a78506e3f91f8da41fa Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:31:50 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[FEAT]=20Result=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CatsnapReservation/shared/ResultCode.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/ResultCode.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/ResultCode.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/ResultCode.java new file mode 100644 index 00000000..81386b55 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/ResultCode.java @@ -0,0 +1,42 @@ +package net.catsnap.CatsnapReservation.shared; + +import org.springframework.http.HttpStatusCode; + +/** + * 결과 코드 인터페이스 + *

+ * 모든 레이어에서 공통으로 사용하는 결과 코드 인터페이스입니다. + * 도메인 에러와 프레젠테이션 에러 모두 이 인터페이스를 구현합니다. + *

+ *

+ * 결과 코드 형식: + *

    + *
  • 성공 코드는 "S"로 시작 (예: SC000, SM001)
  • + *
  • 에러 코드는 "E"로 시작 (예: EC000, EM001)
  • + *
  • 2번째 문자는 도메인 카테고리를 나타냄 - R : reservation 모듈
  • + *
+ *

+ */ +public interface ResultCode { + + /** + * HTTP 상태 코드를 반환합니다. + *

+ * ResponseEntity의 상태 코드로 사용됩니다. + *

+ */ + HttpStatusCode getHttpStatus(); + + /** + * 비즈니스 상태 코드를 반환합니다. + *

+ * 응답 body에 포함되어 더 세밀한 상태를 나타냅니다. + *

+ */ + String getCode(); + + /** + * 상태에 대한 설명 메시지를 반환합니다. + */ + String getMessage(); +} From b7ba3ca0d5d0f303ec0216480bbf57092b27a503 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:33:41 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[FEAT]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20properties=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reservation/src/test/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reservation/src/test/resources/application.yml b/reservation/src/test/resources/application.yml index d11f286a..f97c0ae0 100644 --- a/reservation/src/test/resources/application.yml +++ b/reservation/src/test/resources/application.yml @@ -17,3 +17,6 @@ spring: h2: console: enabled: false + +passport: + secret-key: itstestsecreykey123123123itstestsecreykey123123123 \ No newline at end of file From 3693d02e692cb41fedcad207d57bd1e8a63df484 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:35:49 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[FEAT]=20=EA=B6=8C=ED=95=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interceptor/AbstractAuthInterceptor.java | 151 +++++++++++++++++ .../web/interceptor/AdminInterceptor.java | 44 +++++ .../web/interceptor/AnyUserInterceptor.java | 51 ++++++ .../interceptor/LoginModelInterceptor.java | 52 ++++++ .../LoginPhotographerInterceptor.java | 54 ++++++ .../web/interceptor/LoginUserInterceptor.java | 54 ++++++ .../shared/fixture/PassportTestHelper.java | 124 ++++++++++++++ .../web/interceptor/AdminInterceptorTest.java | 146 ++++++++++++++++ .../interceptor/AnyUserInterceptorTest.java | 153 +++++++++++++++++ .../LoginModelInterceptorTest.java | 142 ++++++++++++++++ .../LoginPhotographerInterceptorTest.java | 142 ++++++++++++++++ .../interceptor/LoginUserInterceptorTest.java | 158 ++++++++++++++++++ 12 files changed, 1271 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AbstractAuthInterceptor.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptor.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptor.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptor.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptor.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptor.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/fixture/PassportTestHelper.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptorTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptorTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptorTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptorTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptorTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AbstractAuthInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AbstractAuthInterceptor.java new file mode 100644 index 00000000..d9099fde --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AbstractAuthInterceptor.java @@ -0,0 +1,151 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.lang.annotation.Annotation; +import java.util.List; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import net.catsnap.shared.passport.domain.exception.ExpiredPassportException; +import net.catsnap.shared.passport.domain.exception.InvalidPassportException; +import net.catsnap.shared.passport.domain.exception.PassportParsingException; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 인증/인가를 처리하는 인터셉터의 추상 클래스입니다. + *

+ * 특정 어노테이션이 붙은 컨트롤러 메서드에 대해 권한을 검증합니다. 하위 클래스는 검증할 어노테이션 타입과 허용할 권한 목록을 지정하여 구체적인 인터셉터를 구현합니다. + *

+ * + * @param 검증 대상 어노테이션 타입 + */ +public abstract class AbstractAuthInterceptor implements + HandlerInterceptor { + + /** + * 검증할 어노테이션의 타입 + */ + private final Class targetAnnotationType; + + /** + * 접근을 허용할 권한 목록 + */ + private final List allowedAuthorities; + + /** + * Passport를 파싱하는 핸들러 + */ + private final PassportHandler passportHandler; + + /** + * 인터셉터를 생성합니다. + * + * @param targetAnnotationType 검증할 어노테이션 클래스 + * @param allowedAuthorities 접근을 허용할 권한 목록 + * @param passportHandler Passport 파싱 핸들러 + */ + protected AbstractAuthInterceptor(Class targetAnnotationType, + List allowedAuthorities, PassportHandler passportHandler) { + this.targetAnnotationType = targetAnnotationType; + this.allowedAuthorities = allowedAuthorities; + this.passportHandler = passportHandler; + } + + /** + * 요청 전처리 핸들러입니다. + *

+ * 다음 순서로 검증을 수행합니다: + *

    + *
  1. 핸들러가 컨트롤러 메서드인지 확인
  2. + *
  3. 대상 어노테이션이 메서드에 존재하는지 확인
  4. + *
  5. 사용자의 권한이 허용 목록에 포함되는지 검증
  6. + *
+ *

+ * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param handler 핸들러 객체 + * @return 다음 인터셉터 체인 진행 여부 (true: 진행, false: 중단) + * @throws Exception 예외 발생 시 + */ + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + + // 1. 핸들러가 컨트롤러 메서드인지 확인 + HandlerMethod handlerMethod = extractHandlerMethod(handler); + if (handlerMethod == null) { + return true; + } + + // 2. 대상 어노테이션이 메서드에 존재하는지 확인 + A annotation = findAnnotationOnMethod(handlerMethod); + if (annotation == null) { + return true; + } + + // 3. 사용자 권한 검증 + validateUserAuthority(request); + return true; + } + + /** + * 핸들러 객체가 HandlerMethod인 경우 추출합니다. + * + * @param handler 핸들러 객체 + * @return HandlerMethod 또는 null (HandlerMethod가 아닌 경우) + */ + private HandlerMethod extractHandlerMethod(Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return null; + } + return handlerMethod; + } + + /** + * 메서드에 대상 어노테이션이 존재하는지 확인합니다. + * + * @param handlerMethod 핸들러 메서드 + * @return 어노테이션 객체 또는 null (어노테이션이 없는 경우) + */ + private A findAnnotationOnMethod(HandlerMethod handlerMethod) { + return handlerMethod.getMethodAnnotation(targetAnnotationType); + } + + /** + * 사용자의 권한을 검증합니다. + *

+ * 현재 사용자의 권한이 허용된 권한 목록({@link #allowedAuthorities})에 포함되는지 확인합니다. 게이트웨이에서 발급한 서명된 Passport + * 헤더(X-Passport)를 파싱하여 사용자 권한을 확인합니다. + *

+ * + * @param request HTTP 요청 객체 + * @throws PresentationException 인증 정보가 없거나 유효하지 않은 경우 (401), 접근 권한이 없는 경우 (403) + */ + protected void validateUserAuthority(HttpServletRequest request) { + String signedPassport = request.getHeader(PassportHandler.PASSPORT_KEY); + + // 1. Passport 헤더가 없는 경우 예외 발생 + if (signedPassport == null || signedPassport.isBlank()) { + throw new PresentationException(PresentationErrorCode.UNAUTHORIZED); + } + + // 2. 서명된 Passport를 파싱 및 검증 + // 3. 사용자 권한이 허용 목록에 포함되는지 확인 + try { + Passport passport = passportHandler.parse(signedPassport); + + if (!allowedAuthorities.contains(passport.authority())) { + throw new PresentationException(PresentationErrorCode.FORBIDDEN); + } + } catch (PassportParsingException | InvalidPassportException e) { + throw new PresentationException(PresentationErrorCode.INVALID_PASSPORT); + } catch (ExpiredPassportException e) { + throw new PresentationException(PresentationErrorCode.EXPIRED_PASSPORT); + } + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptor.java new file mode 100644 index 00000000..ffbfe8a1 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptor.java @@ -0,0 +1,44 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import java.util.List; +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.AbstractAuthInterceptor; +import net.catsnap.shared.auth.Admin; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.stereotype.Component; + +/** + * 관리자 권한 검증 인터셉터입니다. + *

+ * {@link Admin} 어노테이션이 붙은 컨트롤러 메서드에 대해 ADMIN 권한을 검증합니다. 게이트웨이에서 발급한 Passport 헤더(X-Passport)의 권한 정보를 + * 확인하여 접근을 제어합니다. + *

+ * + *

허용 권한

+ *
    + *
  • {@link CatsnapAuthority#ADMIN} - 관리자
  • + *
+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class UserController {
+ *
+ *     @Admin // ADMIN 권한만 접근 가능
+ *     @DeleteMapping("/users/{id}")
+ *     public void deleteUser(@PathVariable Long id) {
+ *         // 관리자만 사용자 삭제 가능
+ *     }
+ * }
+ * }
+ * + * @see Admin + * @see AbstractAuthInterceptor + */ +@Component +public class AdminInterceptor extends AbstractAuthInterceptor { + + public AdminInterceptor(PassportHandler passportHandler) { + super(Admin.class, List.of(CatsnapAuthority.ADMIN), passportHandler); + } +} \ No newline at end of file diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptor.java new file mode 100644 index 00000000..ea537374 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptor.java @@ -0,0 +1,51 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import java.util.List; +import net.catsnap.shared.auth.AnyUser; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.stereotype.Component; + +/** + * 모든 사용자 접근 허용 인터셉터입니다. + *

+ * {@link AnyUser} 어노테이션이 붙은 컨트롤러 메서드에 대해 모든 권한의 접근을 허용합니다. 익명 사용자를 포함한 모든 사용자가 접근할 수 있지만, 반드시 인증 + * 헤더는 필요합니다. + *

+ * + *

허용 권한

+ *
    + *
  • {@link CatsnapAuthority#ANONYMOUS} - 익명 사용자
  • + *
  • {@link CatsnapAuthority#MODEL} - 모델
  • + *
  • {@link CatsnapAuthority#PHOTOGRAPHER} - 사진작가
  • + *
  • {@link CatsnapAuthority#ADMIN} - 관리자
  • + *
+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class PhotoController {
+ *
+ *     @AnyUser // 모든 인증된 사용자 접근 가능
+ *     @GetMapping("/photos/{id}")
+ *     public PhotoResponse getPhoto(@PathVariable Long id) {
+ *         // 익명 사용자 포함 모든 사용자가 사진 조회 가능
+ *     }
+ * }
+ * }
+ * + * @see AnyUser + * @see AbstractAuthInterceptor + */ +@Component +public class AnyUserInterceptor extends AbstractAuthInterceptor { + + public AnyUserInterceptor(PassportHandler passportHandler) { + super(AnyUser.class, List.of( + CatsnapAuthority.ANONYMOUS, + CatsnapAuthority.MODEL, + CatsnapAuthority.PHOTOGRAPHER, + CatsnapAuthority.ADMIN + ), passportHandler); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptor.java new file mode 100644 index 00000000..6728999f --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptor.java @@ -0,0 +1,52 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import java.util.List; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginModel; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.stereotype.Component; + +/** + * 모델 권한 검증 인터셉터입니다. + *

+ * {@link LoginModel} 어노테이션이 붙은 컨트롤러 메서드에 대해 모델 권한을 검증합니다. 모델(MODEL)과 관리자(ADMIN)만 접근할 수 있습니다. + *

+ * + *

허용 권한

+ *
    + *
  • {@link CatsnapAuthority#MODEL} - 모델
  • + *
  • {@link CatsnapAuthority#ADMIN} - 관리자
  • + *
+ * + *

거부 권한

+ *
    + *
  • {@link CatsnapAuthority#PHOTOGRAPHER} - 사진작가
  • + *
  • {@link CatsnapAuthority#ANONYMOUS} - 익명 사용자
  • + *
+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class BookingController {
+ *
+ *     @LoginModel // 모델과 관리자만 접근 가능
+ *     @GetMapping("/bookings/my")
+ *     public List getMyBookings() {
+ *         // 모델이 자신의 촬영 예약 목록 조회
+ *     }
+ * }
+ * }
+ * + * @see LoginModel + * @see AbstractAuthInterceptor + */ +@Component +public class LoginModelInterceptor extends AbstractAuthInterceptor { + + public LoginModelInterceptor(PassportHandler passportHandler) { + super(LoginModel.class, List.of( + CatsnapAuthority.MODEL, + CatsnapAuthority.ADMIN + ), passportHandler); + } +} \ No newline at end of file diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptor.java new file mode 100644 index 00000000..c6d8ab74 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptor.java @@ -0,0 +1,54 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import java.util.List; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginPhotographer; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.stereotype.Component; + +/** + * 사진작가 권한 검증 인터셉터입니다. + *

+ * {@link LoginPhotographer} 어노테이션이 붙은 컨트롤러 메서드에 대해 사진작가 권한을 검증합니다. 사진작가(PHOTOGRAPHER)와 관리자(ADMIN)만 + * 접근할 수 있습니다. + *

+ * + *

허용 권한

+ *
    + *
  • {@link CatsnapAuthority#PHOTOGRAPHER} - 사진작가
  • + *
  • {@link CatsnapAuthority#ADMIN} - 관리자
  • + *
+ * + *

거부 권한

+ *
    + *
  • {@link CatsnapAuthority#MODEL} - 모델
  • + *
  • {@link CatsnapAuthority#ANONYMOUS} - 익명 사용자
  • + *
+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class PortfolioController {
+ *
+ *     @LoginPhotographer // 사진작가와 관리자만 접근 가능
+ *     @PostMapping("/portfolios")
+ *     public PortfolioResponse createPortfolio(@RequestBody PortfolioRequest request) {
+ *         // 사진작가가 포트폴리오 생성
+ *     }
+ * }
+ * }
+ * + * @see LoginPhotographer + * @see AbstractAuthInterceptor + */ +@Component +public class LoginPhotographerInterceptor + extends AbstractAuthInterceptor { + + public LoginPhotographerInterceptor(PassportHandler passportHandler) { + super(LoginPhotographer.class, List.of( + CatsnapAuthority.PHOTOGRAPHER, + CatsnapAuthority.ADMIN + ), passportHandler); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptor.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptor.java new file mode 100644 index 00000000..d029a480 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptor.java @@ -0,0 +1,54 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import java.util.List; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginUser; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.stereotype.Component; + +/** + * 로그인한 사용자 권한 검증 인터셉터입니다. + *

+ * {@link LoginUser} 어노테이션이 붙은 컨트롤러 메서드에 대해 로그인한 사용자만 접근을 허용합니다. 익명(ANONYMOUS) 사용자는 접근할 수 없으며, 실제 + * 계정을 가진 사용자만 접근 가능합니다. + *

+ * + *

허용 권한

+ *
    + *
  • {@link CatsnapAuthority#MODEL} - 모델
  • + *
  • {@link CatsnapAuthority#PHOTOGRAPHER} - 사진작가
  • + *
  • {@link CatsnapAuthority#ADMIN} - 관리자
  • + *
+ * + *

거부 권한

+ *
    + *
  • {@link CatsnapAuthority#ANONYMOUS} - 익명 사용자
  • + *
+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class ProfileController {
+ *
+ *     @LoginUser // 로그인한 사용자만 접근 가능
+ *     @GetMapping("/profile")
+ *     public ProfileResponse getMyProfile() {
+ *         // 모델, 사진작가, 관리자 모두 자신의 프로필 조회 가능
+ *     }
+ * }
+ * }
+ * + * @see LoginUser + * @see AbstractAuthInterceptor + */ +@Component +public class LoginUserInterceptor extends AbstractAuthInterceptor { + + public LoginUserInterceptor(PassportHandler passportHandler) { + super(LoginUser.class, List.of( + CatsnapAuthority.MODEL, + CatsnapAuthority.PHOTOGRAPHER, + CatsnapAuthority.ADMIN + ), passportHandler); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/fixture/PassportTestHelper.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/fixture/PassportTestHelper.java new file mode 100644 index 00000000..a34a9fd7 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/fixture/PassportTestHelper.java @@ -0,0 +1,124 @@ +package net.catsnap.CatsnapReservation.shared.fixture; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +/** + * 테스트용 Passport 헤더 설정 헬퍼 클래스 + *

+ * MockMvc 테스트에서 서명된 Passport 헤더를 편리하게 설정할 수 있도록 도와줍니다. + * 게이트웨이에서 발급하는 서명된 Passport 헤더(X-Passport)를 시뮬레이션합니다. + *

+ * + *

사용 예시

+ *
{@code
+ * @Autowired
+ * private PassportTestHelper passportTestHelper;
+ *
+ * // 관리자 권한으로 요청
+ * mockMvc.perform(passportTestHelper.withAdmin(get("/api/users")))
+ *     .andExpect(status().isOk());
+ *
+ * // 사진작가 권한으로 요청
+ * mockMvc.perform(passportTestHelper.withPhotographer(post("/api/portfolios"), 100L)
+ *     .content(requestBody))
+ *     .andExpect(status().isCreated());
+ *
+ * // 커스텀 권한으로 요청
+ * mockMvc.perform(passportTestHelper.withAuthority(get("/api/profile"), 1L, CatsnapAuthority.MODEL))
+ *     .andExpect(status().isOk());
+ * }
+ */ +@TestComponent +public class PassportTestHelper { + + private final PassportHandler passportHandler; + + private static final String PASSPORT_HEADER = PassportHandler.PASSPORT_KEY; + private static final Long DEFAULT_ADMIN_USER_ID = 1L; + private static final Long DEFAULT_ANONYMOUS_USER_ID = -1L; + private static final int DEFAULT_EXPIRATION_MINUTES = 30; + private static final byte DEFAULT_PASSPORT_VERSION = 1; + + @Autowired + public PassportTestHelper(PassportHandler passportHandler) { + this.passportHandler = passportHandler; + } + + /** + * 관리자(ADMIN) 권한으로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withAdmin(MockHttpServletRequestBuilder builder) { + return withAuthority(builder, DEFAULT_ADMIN_USER_ID, CatsnapAuthority.ADMIN); + } + + /** + * 관리자(ADMIN) 권한으로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withAdmin(MockHttpServletRequestBuilder builder, + Long userId) { + return withAuthority(builder, userId, CatsnapAuthority.ADMIN); + } + + /** + * 사진작가(PHOTOGRAPHER) 권한으로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withPhotographer( + MockHttpServletRequestBuilder builder, Long userId) { + return withAuthority(builder, userId, CatsnapAuthority.PHOTOGRAPHER); + } + + /** + * 모델(MODEL) 권한으로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withModel(MockHttpServletRequestBuilder builder, + Long userId) { + return withAuthority(builder, userId, CatsnapAuthority.MODEL); + } + + /** + * 익명(ANONYMOUS) 사용자로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withAnonymous( + MockHttpServletRequestBuilder builder) { + return withAuthority(builder, DEFAULT_ANONYMOUS_USER_ID, CatsnapAuthority.ANONYMOUS); + } + + /** + * 지정된 권한으로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withAuthority( + MockHttpServletRequestBuilder builder, + Long userId, + CatsnapAuthority authority) { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(DEFAULT_EXPIRATION_MINUTES, ChronoUnit.MINUTES); + + Passport passport = new Passport(DEFAULT_PASSPORT_VERSION, userId, authority, now, exp); + String signedPassport = passportHandler.sign(passport); + + return builder.header(PASSPORT_HEADER, signedPassport); + } + + /** + * 유효하지 않은 Passport로 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withInvalidPassport( + MockHttpServletRequestBuilder builder) { + return builder.header(PASSPORT_HEADER, "invalid-passport-string"); + } + + /** + * 인증 헤더 없이 요청을 설정합니다. + */ + public MockHttpServletRequestBuilder withoutAuth( + MockHttpServletRequestBuilder builder) { + return builder; + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptorTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptorTest.java new file mode 100644 index 00000000..d4df0db4 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AdminInterceptorTest.java @@ -0,0 +1,146 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.Admin; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import net.catsnap.shared.passport.domain.exception.InvalidPassportException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +@DisplayName("AdminInterceptor 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminInterceptorTest { + + private AdminInterceptor adminInterceptor; + private PassportHandler passportHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + passportHandler = mock(PassportHandler.class); + adminInterceptor = new AdminInterceptor(passportHandler); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void Admin_어노테이션을_붙이지_않으면_통과한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("methodWithoutAnnotation"); + + // when & then + assertTrue(adminInterceptor.preHandle(request, response, handler)); + } + + @Test + void ADMIN_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport adminPassport = new Passport((byte) 1, 1L, CatsnapAuthority.ADMIN, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(adminPassport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("adminMethod"); + + // when & then + assertTrue(adminInterceptor.preHandle(request, response, handler)); + } + + @Test + void 인증_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("adminMethod"); + + // when & then + assertThatThrownBy(() -> adminInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.UNAUTHORIZED); + }); + } + + @Test + void ADMIN이_아닌_권한이면_FORBIDDEN_예외가_발생한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport photographerPassport = new Passport((byte) 1, 2L, CatsnapAuthority.PHOTOGRAPHER, + now, exp); + + when(passportHandler.parse(anyString())).thenReturn(photographerPassport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("adminMethod"); + + // when & then + assertThatThrownBy(() -> adminInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.FORBIDDEN); + }); + } + + @Test + void 유효하지_않은_Passport면_INVALID_PASSPORT_예외가_발생한다() throws Exception { + // given + when(passportHandler.parse(anyString())).thenThrow( + new InvalidPassportException("Invalid passport")); + + request.addHeader("X-Passport", "invalid-passport"); + HandlerMethod handler = createHandlerMethod("adminMethod"); + + // when & then + assertThatThrownBy(() -> adminInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.INVALID_PASSPORT); + }); + } + + private HandlerMethod createHandlerMethod(String methodName) throws NoSuchMethodException { + TestController controller = new TestController(); + Method method = controller.getClass().getMethod(methodName); + return new HandlerMethod(controller, method); + } + + @RestController + static class TestController { + + @GetMapping("/admin") + @Admin + public void adminMethod() { + } + + @GetMapping("/public") + public void methodWithoutAnnotation() { + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptorTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptorTest.java new file mode 100644 index 00000000..3da4b0a6 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/AnyUserInterceptorTest.java @@ -0,0 +1,153 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.AnyUser; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +@DisplayName("AnyUserInterceptor 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AnyUserInterceptorTest { + + private AnyUserInterceptor anyUserInterceptor; + private PassportHandler passportHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + passportHandler = mock(PassportHandler.class); + anyUserInterceptor = new AnyUserInterceptor(passportHandler); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void AnyUser_어노테이션을_붙이지_않으면_통과한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("methodWithoutAnnotation"); + + // when & then + assertTrue(anyUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void ANONYMOUS_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, -1L, CatsnapAuthority.ANONYMOUS, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("anyUserMethod"); + + // when & then + assertTrue(anyUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void MODEL_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 1L, CatsnapAuthority.MODEL, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("anyUserMethod"); + + // when & then + assertTrue(anyUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void PHOTOGRAPHER_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 2L, CatsnapAuthority.PHOTOGRAPHER, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("anyUserMethod"); + + // when & then + assertTrue(anyUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void ADMIN_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 3L, CatsnapAuthority.ADMIN, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("anyUserMethod"); + + // when & then + assertTrue(anyUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void 인증_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("anyUserMethod"); + + // when & then + assertThatThrownBy(() -> anyUserInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.UNAUTHORIZED); + }); + } + + private HandlerMethod createHandlerMethod(String methodName) throws NoSuchMethodException { + TestController controller = new TestController(); + Method method = controller.getClass().getMethod(methodName); + return new HandlerMethod(controller, method); + } + + @RestController + static class TestController { + + @GetMapping("/any") + @AnyUser + public void anyUserMethod() { + } + + @GetMapping("/public") + public void methodWithoutAnnotation() { + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptorTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptorTest.java new file mode 100644 index 00000000..14ac3099 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginModelInterceptorTest.java @@ -0,0 +1,142 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginModel; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +@DisplayName("LoginModelInterceptor 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class LoginModelInterceptorTest { + + private LoginModelInterceptor loginModelInterceptor; + private PassportHandler passportHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + passportHandler = mock(PassportHandler.class); + loginModelInterceptor = new LoginModelInterceptor(passportHandler); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void LoginModel_어노테이션을_붙이지_않으면_통과한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("methodWithoutAnnotation"); + + // when & then + assertTrue(loginModelInterceptor.preHandle(request, response, handler)); + } + + @Test + void MODEL_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 1L, CatsnapAuthority.MODEL, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("modelMethod"); + + // when & then + assertTrue(loginModelInterceptor.preHandle(request, response, handler)); + } + + @Test + void ADMIN_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 2L, CatsnapAuthority.ADMIN, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("modelMethod"); + + // when & then + assertTrue(loginModelInterceptor.preHandle(request, response, handler)); + } + + @Test + void PHOTOGRAPHER_권한이면_FORBIDDEN_예외가_발생한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 3L, CatsnapAuthority.PHOTOGRAPHER, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("modelMethod"); + + // when & then + assertThatThrownBy(() -> loginModelInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.FORBIDDEN); + }); + } + + @Test + void 인증_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("modelMethod"); + + // when & then + assertThatThrownBy(() -> loginModelInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.UNAUTHORIZED); + }); + } + + private HandlerMethod createHandlerMethod(String methodName) throws NoSuchMethodException { + TestController controller = new TestController(); + Method method = controller.getClass().getMethod(methodName); + return new HandlerMethod(controller, method); + } + + @RestController + static class TestController { + + @GetMapping("/model") + @LoginModel + public void modelMethod() { + } + + @GetMapping("/public") + public void methodWithoutAnnotation() { + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptorTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptorTest.java new file mode 100644 index 00000000..b809e840 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginPhotographerInterceptorTest.java @@ -0,0 +1,142 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginPhotographer; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +@DisplayName("LoginPhotographerInterceptor 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class LoginPhotographerInterceptorTest { + + private LoginPhotographerInterceptor loginPhotographerInterceptor; + private PassportHandler passportHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + passportHandler = mock(PassportHandler.class); + loginPhotographerInterceptor = new LoginPhotographerInterceptor(passportHandler); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void LoginPhotographer_어노테이션을_붙이지_않으면_통과한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("methodWithoutAnnotation"); + + // when & then + assertTrue(loginPhotographerInterceptor.preHandle(request, response, handler)); + } + + @Test + void PHOTOGRAPHER_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 1L, CatsnapAuthority.PHOTOGRAPHER, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("photographerMethod"); + + // when & then + assertTrue(loginPhotographerInterceptor.preHandle(request, response, handler)); + } + + @Test + void ADMIN_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 2L, CatsnapAuthority.ADMIN, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("photographerMethod"); + + // when & then + assertTrue(loginPhotographerInterceptor.preHandle(request, response, handler)); + } + + @Test + void MODEL_권한이면_FORBIDDEN_예외가_발생한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 3L, CatsnapAuthority.MODEL, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("photographerMethod"); + + // when & then + assertThatThrownBy(() -> loginPhotographerInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.FORBIDDEN); + }); + } + + @Test + void 인증_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("photographerMethod"); + + // when & then + assertThatThrownBy(() -> loginPhotographerInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.UNAUTHORIZED); + }); + } + + private HandlerMethod createHandlerMethod(String methodName) throws NoSuchMethodException { + TestController controller = new TestController(); + Method method = controller.getClass().getMethod(methodName); + return new HandlerMethod(controller, method); + } + + @RestController + static class TestController { + + @GetMapping("/photographer") + @LoginPhotographer + public void photographerMethod() { + } + + @GetMapping("/public") + public void methodWithoutAnnotation() { + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptorTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptorTest.java new file mode 100644 index 00000000..476610c3 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/interceptor/LoginUserInterceptorTest.java @@ -0,0 +1,158 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.auth.LoginUser; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; + +@DisplayName("LoginUserInterceptor 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class LoginUserInterceptorTest { + + private LoginUserInterceptor loginUserInterceptor; + private PassportHandler passportHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + passportHandler = mock(PassportHandler.class); + loginUserInterceptor = new LoginUserInterceptor(passportHandler); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void LoginUser_어노테이션을_붙이지_않으면_통과한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("methodWithoutAnnotation"); + + // when & then + assertTrue(loginUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void MODEL_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 1L, CatsnapAuthority.MODEL, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("loginUserMethod"); + + // when & then + assertTrue(loginUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void PHOTOGRAPHER_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 2L, CatsnapAuthority.PHOTOGRAPHER, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("loginUserMethod"); + + // when & then + assertTrue(loginUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void ADMIN_권한이_있으면_통과한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, 3L, CatsnapAuthority.ADMIN, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("loginUserMethod"); + + // when & then + assertTrue(loginUserInterceptor.preHandle(request, response, handler)); + } + + @Test + void ANONYMOUS_권한이면_FORBIDDEN_예외가_발생한다() throws Exception { + // given + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, -1L, CatsnapAuthority.ANONYMOUS, now, exp); + + when(passportHandler.parse(anyString())).thenReturn(passport); + + request.addHeader("X-Passport", "signed-passport-string"); + HandlerMethod handler = createHandlerMethod("loginUserMethod"); + + // when & then + assertThatThrownBy(() -> loginUserInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.FORBIDDEN); + }); + } + + @Test + void 인증_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() throws Exception { + // given + HandlerMethod handler = createHandlerMethod("loginUserMethod"); + + // when & then + assertThatThrownBy(() -> loginUserInterceptor.preHandle(request, response, handler)) + .isInstanceOf(PresentationException.class) + .satisfies(e -> { + PresentationException ex = (PresentationException) e; + assertThat(ex.getResultCode()).isEqualTo(PresentationErrorCode.UNAUTHORIZED); + }); + } + + private HandlerMethod createHandlerMethod(String methodName) throws NoSuchMethodException { + TestController controller = new TestController(); + Method method = controller.getClass().getMethod(methodName); + return new HandlerMethod(controller, method); + } + + @RestController + static class TestController { + + @GetMapping("/user") + @LoginUser + public void loginUserMethod() { + } + + @GetMapping("/public") + public void methodWithoutAnnotation() { + } + } +} From feafcd8df0798ca9732cd922ca623956b1b32147 Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:36:03 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[FEAT]=20=EA=B6=8C=ED=95=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/web/config/WebMvcConfig.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java new file mode 100644 index 00000000..7f5fa912 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java @@ -0,0 +1,43 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.config; + +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.AdminInterceptor; +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.AnyUserInterceptor; +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginModelInterceptor; +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginPhotographerInterceptor; +import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginUserInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final AdminInterceptor adminInterceptor; + private final AnyUserInterceptor anyUserInterceptor; + private final LoginUserInterceptor loginUserInterceptor; + private final LoginPhotographerInterceptor loginPhotographerInterceptor; + private final LoginModelInterceptor loginModelInterceptor; + + public WebMvcConfig( + AdminInterceptor adminInterceptor, + AnyUserInterceptor anyUserInterceptor, + LoginUserInterceptor loginUserInterceptor, + LoginPhotographerInterceptor loginPhotographerInterceptor, + LoginModelInterceptor loginModelInterceptor + ) { + this.adminInterceptor = adminInterceptor; + this.anyUserInterceptor = anyUserInterceptor; + this.loginUserInterceptor = loginUserInterceptor; + this.loginPhotographerInterceptor = loginPhotographerInterceptor; + this.loginModelInterceptor = loginModelInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor); + registry.addInterceptor(anyUserInterceptor); + registry.addInterceptor(loginUserInterceptor); + registry.addInterceptor(loginPhotographerInterceptor); + registry.addInterceptor(loginModelInterceptor); + } +} From f8b3806ba8e040856fa58a1f0a1ccdb68d6487fe Mon Sep 17 00:00:00 2001 From: chokyungho Date: Sat, 24 Jan 2026 19:45:17 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[FEAT]=20=EA=B3=B5=ED=86=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=95=EC=9D=98:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/GlobalExceptionHandler.java | 127 +++++++++++++++ .../presentation/response/ResultResponse.java | 51 ++++++ .../success/PresentationSuccessCode.java | 47 ++++++ .../GlobalExceptionHandlerTest.java | 153 ++++++++++++++++++ .../response/ResultResponseTest.java | 70 ++++++++ .../success/PresentationSuccessCodeTest.java | 38 +++++ 6 files changed, 486 insertions(+) create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandler.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponse.java create mode 100644 reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCode.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandlerTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponseTest.java create mode 100644 reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCodeTest.java diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandler.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandler.java new file mode 100644 index 00000000..e5194017 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandler.java @@ -0,0 +1,127 @@ +package net.catsnap.CatsnapReservation.shared.presentation; + +import lombok.extern.slf4j.Slf4j; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.CatsnapReservation.shared.presentation.response.ResultResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +/** + * 전역 예외 처리 핸들러 + *

+ * 애플리케이션에서 발생하는 예외를 일관된 응답 형식으로 변환합니다. + *

+ */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 존재하지 않는 API 엔드포인트 요청 처리 + */ + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity> handleNoHandlerFoundException( + NoHandlerFoundException e) { + log.warn("NoHandlerFoundException: {}", e.getMessage()); + return ResultResponse.of(PresentationErrorCode.NOT_FOUND_API); + } + + /** + * 필수 요청 파라미터 누락 처리 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + log.warn("MissingServletRequestParameterException: {}", e.getMessage()); + return ResultResponse.of(PresentationErrorCode.MISSING_REQUEST_PARAMETER); + } + + /** + * 잘못된 요청 바디 처리 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.warn("HttpMessageNotReadableException: {}", e.getMessage()); + return ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY); + } + + /** + * 지원하지 않는 HTTP 메서드 처리 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e) { + log.warn("HttpRequestMethodNotSupportedException: {}", e.getMessage()); + return ResultResponse.of(PresentationErrorCode.METHOD_NOT_ALLOWED); + } + + /** + * 지원하지 않는 미디어 타입 처리 + */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleHttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException e) { + log.warn("HttpMediaTypeNotSupportedException: {}", e.getMessage()); + return ResultResponse.of(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE); + } + + /** + * @Valid 검증 실패 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldError(); + String errorMessage = + fieldError != null ? fieldError.getDefaultMessage() : "입력값이 올바르지 않습니다."; + + log.warn("MethodArgumentNotValidException: {}", errorMessage); + return ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY); + } + + /** + * 프레젠테이션 예외 처리 (인증/인가 실패 포함) + *

+ * 인증 헤더가 없거나 유효하지 않은 경우, 권한이 없는 경우 등 + * 프레젠테이션 레이어에서 발생하는 예외를 처리합니다. + *

+ */ + @ExceptionHandler(PresentationException.class) + public ResponseEntity> handlePresentationException( + PresentationException e) { + log.warn("PresentationException: [{}] {}", e.getResultCode().getCode(), e.getMessage()); + return ResultResponse.of(e.getResultCode()); + } + + /** + * 도메인 예외 처리 + *

+ * DomainException에 포함된 ResultCode를 사용하여 응답을 생성합니다. + *

+ */ + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomainException(DomainException e) { + log.warn("DomainException: [{}] {}", e.getResultCode().getCode(), e.getMessage()); + return ResultResponse.of(e.getResultCode()); + } + + /** + * 기타 모든 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error("Unexpected exception occurred", e); + return ResultResponse.of(PresentationErrorCode.INTERNAL_SERVER_ERROR); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponse.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponse.java new file mode 100644 index 00000000..d4a50993 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponse.java @@ -0,0 +1,51 @@ +package net.catsnap.CatsnapReservation.shared.presentation.response; + +import lombok.Builder; +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.springframework.http.ResponseEntity; + +/** + * API 공통 응답 포맷 + *

+ * HTTP 상태 코드는 ResponseEntity의 헤더에 설정되며, + * 응답 body에는 비즈니스 상태 코드(code)와 메시지, 데이터만 포함됩니다. + *

+ */ +@Builder +@Getter +public class ResultResponse { + + private final String code; + + private final String message; + + private final T data; + + /** + * 데이터를 포함한 응답을 생성합니다. + * + * @param resultCode 결과 코드 + * @param data 응답 데이터 + * @return ResponseEntity로 래핑된 응답 + */ + public static ResponseEntity> of(ResultCode resultCode, T data) { + return ResponseEntity + .status(resultCode.getHttpStatus()) + .body(ResultResponse.builder() + .code(resultCode.getCode()) + .message(resultCode.getMessage()) + .data(data) + .build()); + } + + /** + * 데이터가 없는 응답을 생성합니다. + * + * @param resultCode 결과 코드 + * @return ResponseEntity로 래핑된 응답 + */ + public static ResponseEntity> of(ResultCode resultCode) { + return of(resultCode, null); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCode.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCode.java new file mode 100644 index 00000000..9756c606 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCode.java @@ -0,0 +1,47 @@ +package net.catsnap.CatsnapReservation.shared.presentation.success; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +/** + * 프레젠테이션 레이어 성공 코드 + *

+ * API 요청 처리 성공 시 사용하는 공통 응답 코드를 정의합니다. + *

+ */ +@Getter +@RequiredArgsConstructor +public enum PresentationSuccessCode implements ResultCode { + + /** + * 데이터 조회 성공 + */ + READ(HttpStatus.OK, "SA000", "성공적으로 데이터를 조회했습니다."), + + /** + * 데이터 생성 성공 + */ + CREATE(HttpStatus.CREATED, "SA001", "성공적으로 데이터를 생성했습니다."), + + /** + * 데이터 수정 성공 + */ + UPDATE(HttpStatus.OK, "SA002", "성공적으로 데이터를 수정했습니다."), + + /** + * 데이터 삭제 성공 + */ + DELETE(HttpStatus.OK, "SA003", "성공적으로 데이터를 삭제했습니다."), + + /** + * 작업 완료 (데이터 없음) + */ + NO_CONTENT(HttpStatus.NO_CONTENT, "SA004", "요청이 성공적으로 처리되었습니다."); + + private final HttpStatusCode httpStatus; + private final String code; + private final String message; +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandlerTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..c671c5ef --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandlerTest.java @@ -0,0 +1,153 @@ +package net.catsnap.CatsnapReservation.shared.presentation; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.CatsnapReservation.shared.presentation.response.ResultResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@DisplayName("GlobalExceptionHandler 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + } + + @Test + void NoHandlerFoundException_처리시_NOT_FOUND_API_응답을_반환한다() { + // given + NoHandlerFoundException e = new NoHandlerFoundException("GET", "/api/not-found", null); + + // when + ResponseEntity> response = handler.handleNoHandlerFoundException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody().getCode()).isEqualTo(PresentationErrorCode.NOT_FOUND_API.getCode()); + } + + @Test + void MissingServletRequestParameterException_처리시_MISSING_REQUEST_PARAMETER_응답을_반환한다() { + // given + MissingServletRequestParameterException e = + new MissingServletRequestParameterException("param", "String"); + + // when + ResponseEntity> response = + handler.handleMissingServletRequestParameterException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.MISSING_REQUEST_PARAMETER.getCode()); + } + + @Test + void HttpMessageNotReadableException_처리시_INVALID_REQUEST_BODY_응답을_반환한다() { + // given + HttpMessageNotReadableException e = + new HttpMessageNotReadableException("Invalid JSON", (org.springframework.http.HttpInputMessage) null); + + // when + ResponseEntity> response = + handler.handleHttpMessageNotReadableException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.INVALID_REQUEST_BODY.getCode()); + } + + @Test + void HttpRequestMethodNotSupportedException_처리시_METHOD_NOT_ALLOWED_응답을_반환한다() { + // given + HttpRequestMethodNotSupportedException e = + new HttpRequestMethodNotSupportedException("POST"); + + // when + ResponseEntity> response = + handler.handleHttpRequestMethodNotSupportedException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.METHOD_NOT_ALLOWED.getCode()); + } + + @Test + void HttpMediaTypeNotSupportedException_처리시_UNSUPPORTED_MEDIA_TYPE_응답을_반환한다() { + // given + HttpMediaTypeNotSupportedException e = + new HttpMediaTypeNotSupportedException("Unsupported media type"); + + // when + ResponseEntity> response = + handler.handleHttpMediaTypeNotSupportedException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE.getCode()); + } + + @Test + void PresentationException_처리시_해당_ErrorCode_응답을_반환한다() { + // given + PresentationException e = new PresentationException(PresentationErrorCode.UNAUTHORIZED); + + // when + ResponseEntity> response = handler.handlePresentationException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.UNAUTHORIZED.getCode()); + } + + @Test + void DomainException_처리시_해당_ErrorCode_응답을_반환한다() { + // given + DomainException e = new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION); + + // when + ResponseEntity> response = handler.handleDomainException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getCode()) + .isEqualTo(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION.getCode()); + } + + @Test + void 기타_Exception_처리시_INTERNAL_SERVER_ERROR_응답을_반환한다() { + // given + Exception e = new RuntimeException("Unexpected error"); + + // when + ResponseEntity> response = handler.handleException(e); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody().getCode()) + .isEqualTo(PresentationErrorCode.INTERNAL_SERVER_ERROR.getCode()); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponseTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponseTest.java new file mode 100644 index 00000000..a4634552 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/response/ResultResponseTest.java @@ -0,0 +1,70 @@ +package net.catsnap.CatsnapReservation.shared.presentation.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.shared.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; + +@DisplayName("ResultResponse 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ResultResponseTest { + + @Test + void 데이터와_함께_응답을_생성한다() { + // given + TestResultCode resultCode = new TestResultCode(); + String testData = "test data"; + + // when + ResponseEntity> response = ResultResponse.of(resultCode, testData); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("TEST001"); + assertThat(response.getBody().getMessage()).isEqualTo("테스트 성공"); + assertThat(response.getBody().getData()).isEqualTo(testData); + } + + @Test + void 데이터없이_응답을_생성한다() { + // given + TestResultCode resultCode = new TestResultCode(); + + // when + ResponseEntity> response = ResultResponse.of(resultCode); + + // then + assertThat(response.getStatusCode()).isEqualTo(resultCode.getHttpStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("TEST001"); + assertThat(response.getBody().getMessage()).isEqualTo("테스트 성공"); + assertThat(response.getBody().getData()).isNull(); + } + + // 테스트용 ResultCode 구현체 + private static class TestResultCode implements ResultCode { + + @Override + public HttpStatusCode getHttpStatus() { + return HttpStatus.OK; + } + + @Override + public String getCode() { + return "TEST001"; + } + + @Override + public String getMessage() { + return "테스트 성공"; + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCodeTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCodeTest.java new file mode 100644 index 00000000..510ada31 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/success/PresentationSuccessCodeTest.java @@ -0,0 +1,38 @@ +package net.catsnap.CatsnapReservation.shared.presentation.success; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("PresentationSuccessCode 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PresentationSuccessCodeTest { + + @Test + void 모든_SuccessCode는_S로_시작하는_코드를_가진다() { + // when & then + for (PresentationSuccessCode successCode : PresentationSuccessCode.values()) { + assertThat(successCode.getCode()).startsWith("S"); + } + } + + @Test + void 모든_SuccessCode는_HttpStatus를_가진다() { + // when & then + for (PresentationSuccessCode successCode : PresentationSuccessCode.values()) { + assertThat(successCode.getHttpStatus()).isNotNull(); + } + } + + @Test + void 모든_SuccessCode는_메시지를_가진다() { + // when & then + for (PresentationSuccessCode successCode : PresentationSuccessCode.values()) { + assertThat(successCode.getMessage()).isNotBlank(); + } + } +}