From b0493406da2af1d907331b745fed8db72cd25135 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 30 Aug 2026 19:49:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?FIX=20:=20=EC=B1=97=EB=B4=87=20=EC=83=81?= =?UTF-8?q?=EB=8B=B4=20=EB=82=B4=EC=97=AD=20IDOR=20=EA=B3=BC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=C2=B7?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=A7=A4=EC=B9=AD=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전수 점검에서 나온 네 가지를 고친다. 1) 챗봇 조회 7종이 요청 파라미터의 userId 를 그대로 신뢰했다. 로그인만 하면 남의 userId 를 적어 다른 사람의 상담 내역과 세션을 읽을 수 있었다. 챗봇 대화에는 아이 건강·가정 사정이 담긴다. GET /chatbot/messages/intent | date-range | helpful | search GET /chatbot/sessions/status | date-range | count 조회 대상을 인증 주체로 바꿨다. 파라미터는 기존 클라이언트 호환을 위해 남기되 사용하지 않는다. HealthController·NotificationController 가 쓰는 방식과 같다. (이 둘은 이미 클라이언트 userId 를 무시하고 있었다. 챗봇만 빠져 있었다.) 2) BusinessException 이 언제나 400 으로 나갔다. 전역 핸들러가 ErrorCode 를 INVALID_INPUT 으로, 상태를 400 으로 못 박고 있었다. BusinessException 은 CareCodeException 의 하위 타입이라 자기 ErrorCode 와 HttpStatus 를 이미 들고 있는데 그 값을 통째로 버린 것이다. 영향이 큰 쪽은 인증이다. 프런트 인터셉터는 401 에서만 토큰을 갱신하고 로그인으로 보낸다. 세션 만료가 400 으로 나가면 갱신도 재로그인도 일어나지 않고 "입력값이 유효하지 않습니다" 만 보인다. 해당 경로가 9곳 있었다 (HealthService 6, NotificationService 2, JwtService 1). 3) 고정 경로가 앞선 와일드카드에 먹혀 공개로 선언돼 있었다. GET /health/hospitals/likes ← /health/hospitals/* "내가 찜한 병원" GET /community/posts/liked ← /community/posts/* "내가 좋아요한 글" GET /community/posts/bookmarked ← /community/posts/* "내가 북마크한 글" 한 세그먼트라 상세 조회 와일드카드와 모양이 같아 눈에 띄지 않는다. 지금은 컨트롤러가 현재 사용자를 다시 확인해 401 을 내지만, 나중에 userId 파라미터를 받도록 바뀌면 그대로 남의 목록이 열린다. 규칙을 앞으로 옮겼다. 4) 공공데이터 동기화가 비로그인으로 실행 가능했다. POST /api/public/care-facilities/sync-all GET /api/public/care-facilities/swagger/sync /api/public/care-facilities/** 가 통째로 permitAll 이라 그 아래 동기화 트리거까지 열려 있었다. 외부 공공데이터 API 를 페이지 단위로 호출하고 DB 에 쓴다. 누구나 일일 한도를 태우고 DB 를 두드릴 수 있었다는 뜻이다. 이 프로젝트는 "공공데이터 한도 초과" 를 운영 알림으로 잡는데, 그 상황을 외부에서 만들 수 있는 셈이었다. swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로도 실행된다. 같은 기능이 POST /api/admin/public-data/facilities/sync 로 이미 있어 ADMIN 으로 제한했다. 조회(swagger/stats, swagger/db-facilities)는 공개 그대로다. 회귀 테스트 22건. 접근제어 계약 테스트에는 위 경로들을 실제 응답 코드로 고정했다. 409 tests, 0 failures. --- ...tomizedResponseEntityExceptionHandler.java | 23 +++- .../core/security/SecurityConfig.java | 22 +++- .../chatbot/controller/ChatbotController.java | 43 +++--- .../handler/BusinessExceptionStatusTest.java | 99 ++++++++++++++ .../ChatbotControllerOwnershipTest.java | 123 ++++++++++++++++++ .../AccessControlContractTest.java | 46 +++++++ 6 files changed, 334 insertions(+), 22 deletions(-) create mode 100644 src/test/java/com/carecode/core/handler/BusinessExceptionStatusTest.java create mode 100644 src/test/java/com/carecode/domain/chatbot/controller/ChatbotControllerOwnershipTest.java diff --git a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java index 9cc96229..e8cafa44 100644 --- a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java +++ b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java @@ -75,19 +75,30 @@ public ResponseEntity handleResourceNotFoundException(ResourceNot .body(errorResponse); } - // BusinessException 처리 (하위 호환성 유지) + /** + * BusinessException 처리 (하위 호환성 유지). + * + *

예전에는 여기서 ErrorCode 를 {@code INVALID_INPUT} 으로, 상태를 400 으로 고정했다. + * {@code BusinessException} 은 {@code CareCodeException} 의 하위 타입이라 자기 ErrorCode 와 + * HttpStatus 를 이미 들고 있는데, 그 값을 통째로 버린 것이다. + * + *

그래서 세션 만료·권한 없음이 전부 400 으로 나갔다. + * 프런트 인터셉터는 401 에서만 토큰을 갱신하고 로그인으로 보내므로, + * 만료된 세션으로 건강기록이나 알림에 접근하면 갱신도 재로그인도 일어나지 않고 + * "입력값이 유효하지 않습니다" 만 보였다. (해당 경로 9곳: HealthService 6, NotificationService 2, JwtService 1) + */ @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusinessException(BusinessException ex, WebRequest request) { - log.warn("BusinessException 발생: {}", ex.getMessage()); - + log.warn("BusinessException 발생: {} - {}", ex.getErrorCode().getCode(), ex.getMessage()); + ErrorResponse errorResponse = ErrorResponse.of( - ErrorCode.INVALID_INPUT, + ex.getErrorCode(), ex.getMessage(), request.getDescription(false) ); - + return ResponseEntity - .status(HttpStatus.BAD_REQUEST) + .status(ex.getHttpStatus()) .body(errorResponse); } diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 55d40468..b1519b08 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -146,7 +146,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/facilities/*/rating").authenticated() .requestMatchers("/facilities/*/rating").permitAll() - // 돌봄시설 공공데이터 API (공개 접근) + // 돌봄시설 공공데이터 API — 조회만 공개다. + // + // 동기화는 외부 공공데이터 API 를 페이지 단위로 호출하고 DB 에 쓴다. + // 공개로 두면 누구나 공공데이터 일일 한도를 태우고 DB 를 두드릴 수 있다. + // (이 프로젝트는 "공공데이터 한도 초과" 를 운영 알림으로 잡고 있는데, + // 그 상황을 외부에서 마음대로 만들 수 있는 셈이다.) + // swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로도 실행된다. + // + // 같은 기능이 POST /api/admin/public-data/facilities/sync 로 이미 있다. + .requestMatchers("/api/public/care-facilities/sync-all").hasRole("ADMIN") + .requestMatchers("/api/public/care-facilities/swagger/sync").hasRole("ADMIN") .requestMatchers("/api/public/care-facilities/**").permitAll() // 병원 조회는 로그인 전에도 보여야 한다. 실제 경로가 /health/hospitals/** 라 @@ -155,6 +165,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/health/hospitals/nearby").permitAll() .requestMatchers(HttpMethod.GET, "/health/hospitals/popular").permitAll() .requestMatchers(HttpMethod.GET, "/health/hospitals/type/*").permitAll() + // "내가 찜한 병원" 은 개인 목록이다. 경로가 한 세그먼트라 바로 아래 + // /health/hospitals/* 와일드카드에 먼저 걸리므로 그보다 앞에 선언해야 한다. + // (병원 상세 /health/hospitals/{id} 와 같은 모양이라 눈에 잘 띄지 않는다.) + .requestMatchers(HttpMethod.GET, "/health/hospitals/likes").authenticated() .requestMatchers(HttpMethod.GET, "/health/hospitals/*").permitAll() .requestMatchers(HttpMethod.GET, "/health/hospitals/*/reviews").permitAll() .requestMatchers(HttpMethod.GET, "/health/hospitals/*/likes").permitAll() @@ -182,6 +196,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 커뮤니티 API - 조회는 공개, 작성/수정/삭제는 인증 필요 .requestMatchers(HttpMethod.GET, "/community/posts").permitAll() // 게시글 목록 조회 + // "내가 좋아요/북마크한 글" 은 개인 목록이다. 경로가 한 세그먼트라 + // 아래 게시글 상세 와일드카드에 먼저 걸리므로 그보다 앞에 선언한다. + // (현재는 컨트롤러가 현재 사용자를 다시 확인해 401 을 내지만, + // 나중에 userId 파라미터를 받도록 바뀌면 그대로 남의 목록이 열린다.) + .requestMatchers(HttpMethod.GET, "/community/posts/liked").authenticated() + .requestMatchers(HttpMethod.GET, "/community/posts/bookmarked").authenticated() .requestMatchers(HttpMethod.GET, "/community/posts/*").permitAll() // 게시글 상세 조회 .requestMatchers(HttpMethod.GET, "/community/search").permitAll() // 게시글 검색 .requestMatchers(HttpMethod.GET, "/community/popular").permitAll() // 인기 게시글 diff --git a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java index 8ac9ae1c..40e48a19 100644 --- a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java @@ -133,10 +133,10 @@ public ResponseEntity processFeedback( @LogExecutionTime @Operation(summary = "의도 타입별 메시지 조회") public ResponseEntity> getMessagesByIntentType( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "의도 타입 (GREETING, QUESTION, COMPLAINT, THANKS, GOODBYE, HEALTH_INFO, UNKNOWN)", required = true) @RequestParam String intentType) { List messages = chatbotFacade.getMessagesByIntentType( - userId, com.carecode.domain.chatbot.entity.ChatMessage.IntentType.valueOf(intentType)); + currentUserId(), com.carecode.domain.chatbot.entity.ChatMessage.IntentType.valueOf(intentType)); return ResponseEntity.ok(messages); } @@ -145,11 +145,11 @@ public ResponseEntity> getMessagesByIntentTy @LogExecutionTime @Operation(summary = "기간별 메시지 조회") public ResponseEntity> getMessagesByDateRange( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate, @Parameter(description = "종료일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String endDate) { List messages = chatbotFacade.getMessagesByDateRange( - userId, java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate)); + currentUserId(), java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate)); return ResponseEntity.ok(messages); } @@ -158,9 +158,9 @@ public ResponseEntity> getMessagesByDateRang @LogExecutionTime @Operation(summary = "도움됨 여부별 메시지 조회") public ResponseEntity> getMessagesByHelpfulStatus( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "도움됨 여부", required = true) @RequestParam Boolean isHelpful) { - List messages = chatbotFacade.getMessagesByHelpfulStatus(userId, isHelpful); + List messages = chatbotFacade.getMessagesByHelpfulStatus(currentUserId(), isHelpful); return ResponseEntity.ok(messages); } @@ -169,9 +169,9 @@ public ResponseEntity> getMessagesByHelpfulS @LogExecutionTime @Operation(summary = "키워드로 메시지 검색") public ResponseEntity> searchMessagesByKeyword( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "검색 키워드", required = true) @RequestParam String keyword) { - List messages = chatbotFacade.searchMessagesByKeyword(userId, keyword); + List messages = chatbotFacade.searchMessagesByKeyword(currentUserId(), keyword); return ResponseEntity.ok(messages); } @@ -180,10 +180,10 @@ public ResponseEntity> searchMessagesByKeywo @LogExecutionTime @Operation(summary = "상태별 세션 조회", description = "특정 상태의 세션 조회") public ResponseEntity> getSessionsByStatus( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "세션 상태 (ACTIVE, INACTIVE, CLOSED)", required = true) @RequestParam String status) { List sessions = chatbotFacade.getSessionsByStatus( - userId, com.carecode.domain.chatbot.entity.ChatSession.SessionStatus.valueOf(status)); + currentUserId(), com.carecode.domain.chatbot.entity.ChatSession.SessionStatus.valueOf(status)); return ResponseEntity.ok(sessions); } @@ -192,11 +192,11 @@ public ResponseEntity> getSessionsByStatus( @LogExecutionTime @Operation(summary = "기간별 세션 조회", description = "특정 기간의 세션 조회") public ResponseEntity> getSessionsByDateRange( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId, @Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate, @Parameter(description = "종료일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String endDate) { List sessions = chatbotFacade.getSessionsByDateRange( - userId, java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate)); + currentUserId(), java.time.LocalDateTime.parse(startDate), java.time.LocalDateTime.parse(endDate)); return ResponseEntity.ok(sessions); } @@ -205,10 +205,23 @@ public ResponseEntity> getSessionsByDateRange( @LogExecutionTime @Operation(summary = "사용자별 세션 수 조회") public ResponseEntity> getSessionCountByUser( - @Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { - long count = chatbotFacade.getSessionCountByUser(userId); + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) String userId) { + long count = chatbotFacade.getSessionCountByUser(currentUserId()); Map response = new java.util.HashMap<>(); response.put("sessionCount", count); return ResponseEntity.ok(response); } -} \ No newline at end of file + /** + * 조회 대상은 언제나 로그인한 본인이다. + * + *

아래 조회들은 요청 파라미터의 {@code userId} 를 그대로 파사드에 넘기고 있었다. + * 즉 로그인만 하면 남의 {@code userId} 를 적어 다른 사람의 상담 내역과 세션을 + * 그대로 읽을 수 있었다. 챗봇 대화에는 아이 건강·가정 사정이 담긴다. + * + *

파라미터는 기존 클라이언트 호환을 위해 남겨 두되 사용하지 않는다. + * HealthController·NotificationController 가 같은 방식으로 처리한다. + */ + private String currentUserId() { + return currentUserFacade.requireCurrentUserId(); + } +} diff --git a/src/test/java/com/carecode/core/handler/BusinessExceptionStatusTest.java b/src/test/java/com/carecode/core/handler/BusinessExceptionStatusTest.java new file mode 100644 index 00000000..94508e8e --- /dev/null +++ b/src/test/java/com/carecode/core/handler/BusinessExceptionStatusTest.java @@ -0,0 +1,99 @@ +package com.carecode.core.handler; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.ops.OperationalAlerter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * BusinessException 이 자기 ErrorCode 의 상태 코드로 응답하는지 고정한다. + * + *

예전 핸들러는 ErrorCode 를 {@code INVALID_INPUT} 으로, 상태를 400 으로 못 박았다. + * {@code BusinessException} 은 {@code CareCodeException} 의 하위 타입이라 자기 ErrorCode 와 + * HttpStatus 를 이미 들고 있는데 그 값을 통째로 버린 것이다. + * + *

영향이 큰 쪽은 인증이다. 프런트 인터셉터는 401 에서만 토큰을 갱신하고 + * 로그인으로 보낸다(`src/apis/interceptor.ts`). 세션 만료가 400 으로 나가면 + * 갱신도 재로그인도 일어나지 않고 "입력값이 유효하지 않습니다" 만 보인다. + * 실제로 그런 경로가 9곳 있었다 — HealthService 6, NotificationService 2, JwtService 1. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("전역 예외 핸들러 - BusinessException 상태 코드") +class BusinessExceptionStatusTest { + + @Mock private OperationalAlerter alerter; + + private ResponseEntity handle(BusinessException ex) { + CustomizedResponseEntityExceptionHandler handler = + new CustomizedResponseEntityExceptionHandler(alerter); + return handler.handleBusinessException(ex, new ServletWebRequest(new MockHttpServletRequest())); + } + + @ParameterizedTest(name = "{0} → {1}") + @CsvSource({ + "UNAUTHORIZED, 401", + "FORBIDDEN, 403", + "INVALID_INPUT, 400" + }) + @DisplayName("ErrorCode 가 정한 상태 코드로 응답한다") + void usesErrorCodeStatus(ErrorCode errorCode, int expectedStatus) { + ResponseEntity response = + handle(new BusinessException(errorCode, "메시지")); + + assertThat(response.getStatusCode().value()).isEqualTo(expectedStatus); + } + + @Test + @DisplayName("세션 만료는 401 이어야 프런트가 토큰을 갱신한다") + void expiredSessionIsUnauthorized() { + // JwtService.refreshTokens 가 던지는 것과 같은 예외다. + ResponseEntity response = + handle(new BusinessException(ErrorCode.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다.")); + + assertThat(response.getStatusCode().value()) + .as("400 이면 프런트 인터셉터가 갱신도 로그인 리다이렉트도 하지 않는다") + .isEqualTo(401); + } + + @Test + @DisplayName("권한 없음은 403 이어야 입력 오류와 구분된다") + void forbiddenIsNotBadRequest() { + // HealthService 의 "해당 건강 기록에 접근할 권한이 없습니다" 와 같은 예외다. + ResponseEntity response = + handle(new BusinessException(ErrorCode.FORBIDDEN, "해당 건강 기록에 접근할 권한이 없습니다.")); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + @DisplayName("메시지 없는 생성자는 기존대로 400 이다") + void legacyConstructorStaysBadRequest() { + // BusinessException(String) 은 ErrorCode.INVALID_INPUT 을 쓴다. 기존 동작이 바뀌면 안 된다. + assertThat(handle(new BusinessException("입력이 잘못됐습니다")).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + @DisplayName("응답 본문의 코드도 ErrorCode 를 따른다") + void bodyCarriesErrorCode() { + ResponseEntity response = + handle(new BusinessException(ErrorCode.FORBIDDEN, "권한 없음")); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } +} diff --git a/src/test/java/com/carecode/domain/chatbot/controller/ChatbotControllerOwnershipTest.java b/src/test/java/com/carecode/domain/chatbot/controller/ChatbotControllerOwnershipTest.java new file mode 100644 index 00000000..df1629cf --- /dev/null +++ b/src/test/java/com/carecode/domain/chatbot/controller/ChatbotControllerOwnershipTest.java @@ -0,0 +1,123 @@ +package com.carecode.domain.chatbot.controller; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.chatbot.app.ChatbotFacade; +import com.carecode.domain.chatbot.entity.ChatMessage; +import com.carecode.domain.chatbot.entity.ChatSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 챗봇 조회가 요청 파라미터의 userId 를 믿지 않는지 고정한다. + * + *

이 컨트롤러의 조회 7종은 {@code @RequestParam String userId} 를 그대로 파사드에 + * 넘기고 있었다. 로그인만 하면 남의 userId 를 적어 다른 사람의 상담 내역과 세션을 + * 읽을 수 있었다는 뜻이다. 챗봇 대화에는 아이 건강·가정 사정이 담긴다. + * + *

같은 도메인의 HealthController·NotificationController 는 이미 클라이언트가 준 userId 를 + * 무시하고 인증 주체를 쓴다. 여기만 빠져 있었다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ChatbotController - 조회 대상은 인증 주체") +class ChatbotControllerOwnershipTest { + + private static final String ME = "user_me"; + private static final String VICTIM = "user_victim"; + + @Mock private ChatbotFacade chatbotFacade; + @Mock private CurrentUserFacade currentUserFacade; + + @InjectMocks private ChatbotController controller; + + @BeforeEach + void setUp() { + when(currentUserFacade.requireCurrentUserId()).thenReturn(ME); + } + + @Test + @DisplayName("의도별 메시지 조회는 남의 userId 를 무시한다") + void intentSearchIgnoresRequestedUserId() { + controller.getMessagesByIntentType(VICTIM, "QUESTION"); + + verify(chatbotFacade).getMessagesByIntentType(eq(ME), any(ChatMessage.IntentType.class)); + verify(chatbotFacade, never()).getMessagesByIntentType(eq(VICTIM), any()); + } + + @Test + @DisplayName("기간별 메시지 조회는 남의 userId 를 무시한다") + void dateRangeSearchIgnoresRequestedUserId() { + controller.getMessagesByDateRange(VICTIM, "2026-01-01T00:00:00", "2026-12-31T00:00:00"); + + verify(chatbotFacade).getMessagesByDateRange(eq(ME), any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("도움됨 여부별 조회는 남의 userId 를 무시한다") + void helpfulSearchIgnoresRequestedUserId() { + controller.getMessagesByHelpfulStatus(VICTIM, true); + + verify(chatbotFacade).getMessagesByHelpfulStatus(ME, true); + verify(chatbotFacade, never()).getMessagesByHelpfulStatus(eq(VICTIM), any()); + } + + @Test + @DisplayName("키워드 검색은 남의 userId 를 무시한다") + void keywordSearchIgnoresRequestedUserId() { + when(chatbotFacade.searchMessagesByKeyword(any(), any())).thenReturn(List.of()); + + controller.searchMessagesByKeyword(VICTIM, "예방접종"); + + verify(chatbotFacade).searchMessagesByKeyword(ME, "예방접종"); + } + + @Test + @DisplayName("상태별 세션 조회는 남의 userId 를 무시한다") + void sessionsByStatusIgnoreRequestedUserId() { + controller.getSessionsByStatus(VICTIM, "ACTIVE"); + + verify(chatbotFacade).getSessionsByStatus(eq(ME), any(ChatSession.SessionStatus.class)); + } + + @Test + @DisplayName("기간별 세션 조회는 남의 userId 를 무시한다") + void sessionsByDateRangeIgnoreRequestedUserId() { + controller.getSessionsByDateRange(VICTIM, "2026-01-01T00:00:00", "2026-12-31T00:00:00"); + + verify(chatbotFacade).getSessionsByDateRange(eq(ME), any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("세션 수 조회는 남의 userId 를 무시한다") + void sessionCountIgnoresRequestedUserId() { + controller.getSessionCountByUser(VICTIM); + + verify(chatbotFacade).getSessionCountByUser(ME); + verify(chatbotFacade, never()).getSessionCountByUser(VICTIM); + } + + @Test + @DisplayName("userId 를 아예 보내지 않아도 동작한다") + void userIdParameterIsOptional() { + // 파라미터는 호환을 위해 남겨둔 것이라 없어도 된다. + controller.getSessionCountByUser(null); + + verify(chatbotFacade).getSessionCountByUser(ME); + } +} diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index eb43a211..b02025d9 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -121,6 +121,12 @@ void publicPathsDoNotRequireLogin(String path) throws Exception { "/health/statistics", // 좋아요 "여부" 는 내 상태라 공개 조회와 구분해야 한다 "/health/hospitals/1/like-status", + // "내가 찜한 병원" 목록. 경로가 한 세그먼트라 /health/hospitals/* 와일드카드에 + // 먼저 걸려 공개로 선언돼 있었다. 병원 상세와 모양이 같아 눈에 띄지 않는다. + "/health/hospitals/likes", + // 같은 모양의 문제. 게시글 상세(/community/posts/*)에 먹히고 있었다. + "/community/posts/liked", + "/community/posts/bookmarked", "/notifications", "/auth/user/profile", // 본인 계정 API. 로그인 없이 열리면 남의 프로필이 그대로 노출된다 @@ -188,6 +194,46 @@ void privilegedMappingsRemovedFromUserApi(String path) throws Exception { .isIn(404, 405); } + /** + * 공공데이터 동기화는 외부 API 한도를 태우고 DB 에 쓴다. 공개로 두면 누구나 실행할 수 있다. + * + *

{@code /api/public/care-facilities/**} 가 통째로 permitAll 이라, 그 아래 있는 + * 동기화 트리거까지 열려 있었다. {@code swagger/sync} 는 GET 이라 브라우저 접속이나 + * 크롤러만으로도 실행된다. + */ + @Test + @DisplayName("공공데이터 동기화는 비로그인으로 실행할 수 없다") + void publicDataSyncIsNotOpen() throws Exception { + assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + .getResponse().getStatus()) + .as("POST 동기화가 열려 있으면 안 된다") + .isIn(401, 403); + + assertThat(mockMvc.perform(get("/api/public/care-facilities/swagger/sync")).andReturn() + .getResponse().getStatus()) + .as("GET 동기화는 브라우저 접속만으로도 실행된다") + .isIn(401, 403); + } + + @Test + @DisplayName("일반 회원도 공공데이터 동기화를 실행할 수 없다") + @WithMockUser(username = "member@example.com", roles = "PARENT") + void publicDataSyncRequiresAdmin() throws Exception { + assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + .getResponse().getStatus()).isEqualTo(403); + } + + /** 시설·정책 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */ + @ParameterizedTest(name = "{0} 은 여전히 공개다") + @ValueSource(strings = { + "/api/public/care-facilities/swagger/stats", + "/api/public/care-facilities/swagger/db-facilities" + }) + void publicDataReadStaysOpen(String path) throws Exception { + assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) + .isNotIn(401, 403); + } + /** 사용자 목록·검색은 전체 회원 개인정보다. 로그인만 했다고 열리면 안 된다. */ @ParameterizedTest(name = "{0} 은 일반 회원에게 403 이다") @ValueSource(strings = { From d080f4d457baa02c25591cc222f384c8c2e616f4 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 30 Aug 2026 20:46:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?TEST=20:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=9D=B8=EA=B0=80=20=EC=BB=A4=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=E2=80=94=20=EB=A7=A4=ED=95=91=20=EC=A0=84?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EC=9D=B8=EA=B0=80=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EA=B3=BC=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경로 매칭 실수를 네 번 반복했다. /health/** 가 /hospitals/** 를 삼켜 병원 공개 조회가 통째로 401 이메일 인증 화이트리스트가 /users/* 를 가리켜 가입 흐름이 401 /health/hospitals/likes 가 /health/hospitals/* 에 먹혀 개인 목록이 공개 /community/posts/liked|bookmarked 가 게시글 상세 와일드카드에 먹힘 넷 다 규칙 자체는 멀쩡해 보이는데 매칭 순서 때문에 의도와 다르게 동작한 경우다. 계약 테스트에 경로를 하나씩 적는 방식으로는 새 엔드포인트가 생길 때마다 놓친다. 실제로 위 3·4번은 바로 직전에 머지한 기능이 만든 것이다. 기본값을 뒤집는다. 모든 매핑은 인증이 필요하다. 공개는 INTENTIONALLY_PUBLIC 에 적힌 것만. 목록에 없는 경로가 공개로 열리면 테스트가 깨지고 경로와 컨트롤러 이름을 찍어준다. 반대로 목록에 적어뒀는데 실제로는 막혀 있어도 깨진다(이메일 인증 사례가 그랬다). RequestMappingHandlerMapping 에서 매핑을 모두 읽고, AuthorizationFilter 의 판단만 평가한다. 실제 요청을 보내지 않으므로 DB 변경이나 외부 API 호출 같은 부작용이 없다. 매핑을 하나도 읽지 못하면 아무것도 검사하지 않은 채 통과하므로, 훑은 매핑 수의 하한을 함께 확인한다. 조용히 무력화되는 게이트는 없는 것보다 나쁘다. 이 테스트를 붙이자 남은 미선언 공개 엔드포인트가 하나 나왔다. POST /facilities/{id}/view 조회수 증가라 비로그인 방문자도 세야 한다. 의도된 공개로 판단해 근거와 함께 목록에 넣었다. 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다는 점은 주석으로 남겼다. 411 tests, 0 failures. --- docs/reference/access-control-matrix.md | 28 ++ .../EndpointAuthorizationCoverageTest.java | 266 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 798aefa1..9174f4f8 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -251,6 +251,34 @@ flowchart TD **404·403 은 운영 알림을 보내지 않습니다.** 장애가 아니기 때문입니다. 초기에는 봇이 없는 URL 을 긁을 때마다 알림이 울렸고, 그러면 진짜 장애가 소음에 묻힙니다. +## 자동 점검 + +`EndpointAuthorizationCoverageTest` 가 **컨트롤러 매핑 전수**를 SecurityConfig 의 인가 판단과 대조합니다. + +기존 `AccessControlContractTest` 는 경로를 하나씩 적어 확인하는 방식이라, +새 엔드포인트가 생기면 누군가 목록에 추가해야만 검사됩니다. 실제로 그래서 놓쳤습니다. + +| 놓친 사례 | 원인 | +|-----------|------| +| `/health/**` 가 `/hospitals/**` 를 삼킴 | 병원 공개 조회가 통째로 401 | +| 이메일 인증 화이트리스트가 `/users/*` | 실제 매핑은 `/auth/*` — 가입 흐름 401 | +| `/health/hospitals/likes` | `/health/hospitals/*` 에 먹혀 개인 목록이 공개 | +| `/community/posts/liked`, `/bookmarked` | 게시글 상세 와일드카드에 먹힘 | + +넷 다 **규칙 자체는 멀쩡해 보이는데 매칭 순서 때문에 의도와 다르게 동작**한 경우입니다. + +그래서 기본값을 뒤집었습니다. + +> 모든 매핑은 인증이 필요하다. 공개는 테스트의 `INTENTIONALLY_PUBLIC` 에 적힌 것만. + +목록에 없는 경로가 공개로 열리면 테스트가 깨지고, **무엇이 열렸는지 경로와 컨트롤러 이름을 찍어줍니다.** +반대로 목록에 적어뒀는데 실제로는 막혀 있어도 깨집니다(이메일 인증 사례가 그랬습니다). + +실제 요청을 보내지 않고 `AuthorizationFilter` 의 판단만 평가하므로, +DB 변경이나 외부 API 호출 같은 부작용이 없습니다. + +매핑을 하나도 읽지 못하면 아무것도 검사하지 않은 채 통과하므로, 훑은 매핑 수의 하한도 함께 확인합니다. + ## 경로를 추가할 때 1. SecurityConfig 에 규칙을 넣습니다. **와일드카드보다 구체적인 경로를 먼저** 선언합니다. diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java new file mode 100644 index 00000000..c5a38d51 --- /dev/null +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -0,0 +1,266 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 모든 엔드포인트가 "의도적으로" 공개인지 확인한다. + * + *

이 저장소는 같은 유형의 사고를 네 번 겪었다. + *

+ * + *

공통점은 규칙 자체는 멀쩡해 보이는데 매칭 순서 때문에 의도와 다르게 동작한다는 것이다. + * 계약 테스트에 경로를 하나씩 적는 방식으로는 새 엔드포인트가 생길 때마다 놓친다. + * 그래서 방향을 뒤집는다 — 모든 매핑은 기본적으로 인증이 필요하고, 공개는 아래 목록에 + * 적힌 것만이다. 목록에 없는 경로가 공개로 열리면 이 테스트가 깨진다. + * + *

실제 요청을 보내지 않고 인가 판단만 평가한다. 컨트롤러가 실행되지 않으므로 + * DB 변경이나 외부 API 호출 같은 부작용이 없다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_authcov;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAuthorizationCoverageMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@DisplayName("엔드포인트 인가 커버리지") +class EndpointAuthorizationCoverageTest { + + /** + * 비로그인으로 열어야 하는 경로. + * + *

여기에 추가할 때는 "이 응답에 특정 사용자에게 속한 것이 하나라도 들어가는가" 를 묻는다. + * 들어간다면 공개가 아니다. 목록은 {@code docs/reference/access-control-matrix.md} 와 짝을 이룬다. + */ + private static final Set INTENTIONALLY_PUBLIC = new TreeSet<>(Set.of( + // 시스템·문서 + "GET /", "GET /error", "GET /favicon.ico", + "GET /actuator/health", "GET /actuator/info", "GET /actuator/prometheus", + "GET /legal/privacy-policy", "GET /legal/terms", "GET /legal/version", + + // 인증 흐름 (로그인 전에 호출된다) + "POST /auth/login", "POST /auth/register", "POST /auth/refresh", + "POST /auth/send-code", "POST /auth/verify-code", "GET /auth/verify", + "POST /auth/kakao/login", "GET /auth/kakao/login-url", + "POST /auth/kakao/complete-registration", + + // 둘러보기 — 로그인 전에 보여야 가입 전환이 생긴다 + "GET /facilities", "GET /facilities/popular", "GET /facilities/new", + "GET /facilities/radius", "GET /facilities/statistics", "GET /facilities/age", + "GET /facilities/operating-hours", + + "GET /policies", "GET /policies/categories", "GET /policies/statistics", + "GET /policies/active", "GET /policies/age", "GET /policies/child-age", + "GET /policies/latest", "GET /policies/popular", "POST /policies/search", + + "GET /health/hospitals", "GET /health/hospitals/nearby", "GET /health/hospitals/popular", + + "GET /community/posts", "GET /community/search", "GET /community/popular", + "GET /community/latest", "GET /community/tags", "GET /community/search/all", + "GET /community/popular/limit", "GET /community/latest/limit", + + // 공공데이터 조회 (동기화 트리거는 ADMIN 이다) + "GET /api/public/care-facilities/swagger/stats", + "GET /api/public/care-facilities/swagger/db-facilities", + + // 조회수 증가. 쓰기지만 비로그인 방문자의 조회도 세야 하므로 공개다. + // 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다. + // 인기 순위를 조작에 민감하게 다뤄야 한다면 여기부터 손봐야 한다. + "POST /facilities/1/view" + )); + + /** + * 경로 변수를 포함해 공개인 것들. 변수 자리를 채우면 문자열이 달라져 + * 위 목록으로는 표현할 수 없어 접두사로 둔다. + */ + private static final List PUBLIC_PREFIXES = List.of( + "GET /facilities/type/", + "GET /facilities/location/", + "GET /policies/", // 정책 상세 + "GET /facilities/", // 시설 상세·조회수·평점 + "GET /health/hospitals/", // 병원 상세·리뷰·좋아요 수 + "GET /community/posts/", // 게시글 상세·댓글 + "GET /community/tags/", + "GET /files/profile-images/" + ); + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + // actuator 가 같은 타입의 빈을 하나 더 등록한다(controllerEndpointHandlerMapping). + @Autowired @Qualifier("requestMappingHandlerMapping") + RequestMappingHandlerMapping handlerMapping; + @Autowired SecurityFilterChain securityFilterChain; + + private AuthorizationManager authorizationManager() { + for (Filter filter : securityFilterChain.getFilters()) { + if (filter instanceof AuthorizationFilter authorizationFilter) { + return authorizationFilter.getAuthorizationManager(); + } + } + throw new IllegalStateException("AuthorizationFilter 를 찾지 못했습니다."); + } + + private static Authentication anonymous() { + return new AnonymousAuthenticationToken( + "key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + } + + /** 경로 변수 자리를 채운다. 인가는 값에 의존하지 않으므로 아무 값이나 된다. */ + private static String fill(String pattern) { + return pattern.replaceAll("\\{[^/}]+}", "1"); + } + + private boolean isPublic(String method, String path) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setServletPath(path); + AuthorizationDecision decision = + authorizationManager().check(EndpointAuthorizationCoverageTest::anonymous, request); + return decision == null || decision.isGranted(); + } + + private static boolean allowed(String signature) { + if (INTENTIONALLY_PUBLIC.contains(signature)) { + return true; + } + return PUBLIC_PREFIXES.stream().anyMatch(signature::startsWith); + } + + /** + * 실제로 훑은 매핑 수의 하한. + * + *

이게 없으면 매핑을 하나도 못 읽었을 때도 "위반 없음" 으로 통과한다. + * 조용히 아무것도 검사하지 않는 게이트는 없는 것보다 나쁘다 — 통과했다는 착각을 준다. + * (CI 에도 같은 이유로 스키마 검증이 skip 되면 실패시키는 단계가 있다.) + */ + private static final int MIN_EXAMINED_MAPPINGS = 100; + + @Test + @DisplayName("목록에 없는 엔드포인트가 공개로 열려 있지 않다") + void noUndeclaredPublicEndpoint() { + Set unexpected = new TreeSet<>(); + int examined = 0; + + for (Map.Entry entry + : handlerMapping.getHandlerMethods().entrySet()) { + RequestMappingInfo info = entry.getKey(); + + Set patterns = new LinkedHashSet<>(); + if (info.getPathPatternsCondition() != null) { + info.getPathPatternsCondition().getPatterns() + .forEach(p -> patterns.add(p.getPatternString())); + } + if (patterns.isEmpty()) { + continue; + } + + Set methods = new LinkedHashSet<>(); + info.getMethodsCondition().getMethods().forEach(m -> methods.add(m.name())); + if (methods.isEmpty()) { + methods.add("GET"); + } + + for (String pattern : patterns) { + for (String method : methods) { + examined++; + String path = fill(pattern); + if (!isPublic(method, path)) { + continue; + } + String signature = method + " " + path; + if (!allowed(signature)) { + unexpected.add(signature + " (매핑: " + pattern + ", " + + entry.getValue().getBeanType().getSimpleName() + ")"); + } + } + } + } + + assertThat(examined) + .as("매핑을 읽지 못하면 이 테스트는 아무것도 검사하지 않은 채 통과한다") + .isGreaterThanOrEqualTo(MIN_EXAMINED_MAPPINGS); + + assertThat(unexpected) + .as(""" + 비로그인으로 열려 있는데 공개 목록에 없는 엔드포인트다. + 둘 중 하나다. + (1) 정말 공개여야 한다 → INTENTIONALLY_PUBLIC 에 추가하고 근거를 남긴다 + (2) 공개면 안 된다 → SecurityConfig 에서 와일드카드보다 앞에 규칙을 선언한다 + """) + .isEmpty(); + } + + @Test + @DisplayName("공개 목록에 적어둔 경로는 실제로 열려 있다") + void declaredPublicEndpointsAreReallyOpen() { + Set closed = new TreeSet<>(); + + for (String signature : INTENTIONALLY_PUBLIC) { + String[] parts = signature.split(" ", 2); + if (!isPublic(parts[0], parts[1])) { + closed.add(signature); + } + } + + // 목록이 현실과 어긋나면 그것도 버그다. 실제로 이메일 인증 경로가 + // "열어뒀다고 적혀 있는데 실은 막혀 있는" 상태로 오래 남아 있었다. + assertThat(closed) + .as("공개로 선언했지만 인증을 요구한다. 경로가 실제 매핑과 맞는지 확인하라") + .isEmpty(); + } +}