From cc6d4d99fc931edffacead5ab68d79a6590aedd2 Mon Sep 17 00:00:00 2001 From: HandoA01 Date: Sun, 26 Jul 2026 14:43:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(presign/confirm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatImageController.java | 59 +++++++++++++++++++ .../tdd/chat/dto/ChatImageConfirmRequest.java | 13 ++++ .../tdd/chat/dto/ChatImagePresignRequest.java | 12 ++++ .../chat/dto/ChatImagePresignResponse.java | 25 ++++++++ .../tdd/chat/service/ChatImageService.java | 47 +++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 src/main/java/com/leets/tdd/chat/controller/ChatImageController.java create mode 100644 src/main/java/com/leets/tdd/chat/dto/ChatImageConfirmRequest.java create mode 100644 src/main/java/com/leets/tdd/chat/dto/ChatImagePresignRequest.java create mode 100644 src/main/java/com/leets/tdd/chat/dto/ChatImagePresignResponse.java create mode 100644 src/main/java/com/leets/tdd/chat/service/ChatImageService.java diff --git a/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java b/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java new file mode 100644 index 0000000..0f84230 --- /dev/null +++ b/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java @@ -0,0 +1,59 @@ +package com.leets.tdd.chat.controller; + +import com.leets.tdd.chat.dto.ChatImageConfirmRequest; +import com.leets.tdd.chat.dto.ChatImagePresignRequest; +import com.leets.tdd.chat.dto.ChatImagePresignResponse; +import com.leets.tdd.chat.service.ChatImageService; +import com.leets.tdd.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/parties/{partyId}/chat/images") +@RequiredArgsConstructor +public class ChatImageController { + + private final ChatImageService chatImageService; + + @Operation( + summary = "채팅 이미지 업로드 1단계(업로드 URL 발급)", + description = "채팅 이미지를 올릴 Presigned PUT URL과 key를 발급한다(비공개 버킷). 응답으로 받은 " + + "uploadUrl로 브라우저가 S3에 직접 PUT한 뒤, 그 key로 확정(confirm) API를 호출해야 한다. " + + "허용 형식은 JPEG/PNG/WEBP다. Authorization 헤더에 access token(Bearer)이 필요하다." + ) + @SecurityRequirement(name = "bearerAuth") + @PostMapping("/presign") + public ResponseEntity> presignUpload( + @PathVariable Long partyId, + @Valid @RequestBody ChatImagePresignRequest request + ) { + ChatImagePresignResponse response = chatImageService.presignUpload(partyId, request); + return ResponseEntity.ok(ApiResponse.success("업로드 URL이 발급되었습니다.", response)); + } + + @Operation( + summary = "채팅 이미지 업로드 2단계(업로드 확정)", + description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 서버가 실제로 올라간 객체의 " + + "용량/형식을 확인하고, 기준을 벗어나면 객체를 지우고 실패 처리한다. " + + "확정에 성공하면 클라이언트는 받은 key로 { messageType: \"IMAGE\", imageUrl: key } 형태의 " + + "메시지를 WebSocket(/app/parties/{partyId}/chat)으로 발행해 이미지 메시지를 전송한다. " + + "Authorization 헤더에 access token(Bearer)이 필요하다." + ) + @SecurityRequirement(name = "bearerAuth") + @PostMapping("/confirm") + public ResponseEntity> confirmUpload( + @PathVariable Long partyId, + @Valid @RequestBody ChatImageConfirmRequest request + ) { + chatImageService.confirmUpload(partyId, request); + return ResponseEntity.ok(ApiResponse.success("이미지 업로드가 확정되었습니다.", null)); + } +} diff --git a/src/main/java/com/leets/tdd/chat/dto/ChatImageConfirmRequest.java b/src/main/java/com/leets/tdd/chat/dto/ChatImageConfirmRequest.java new file mode 100644 index 0000000..0023823 --- /dev/null +++ b/src/main/java/com/leets/tdd/chat/dto/ChatImageConfirmRequest.java @@ -0,0 +1,13 @@ +package com.leets.tdd.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "채팅 이미지 업로드 확정 요청") +public record ChatImageConfirmRequest( + @Schema(description = "발급 단계에서 받은 S3 객체 key", + example = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg") + @NotBlank(message = "key는 필수입니다.") + String key +) { +} diff --git a/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignRequest.java b/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignRequest.java new file mode 100644 index 0000000..1edb5b7 --- /dev/null +++ b/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignRequest.java @@ -0,0 +1,12 @@ +package com.leets.tdd.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "채팅 이미지 업로드 URL 발급 요청") +public record ChatImagePresignRequest( + @Schema(description = "업로드할 이미지의 Content-Type", example = "image/jpeg") + @NotBlank(message = "contentType은 필수입니다.") + String contentType +) { +} diff --git a/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignResponse.java b/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignResponse.java new file mode 100644 index 0000000..26d034c --- /dev/null +++ b/src/main/java/com/leets/tdd/chat/dto/ChatImagePresignResponse.java @@ -0,0 +1,25 @@ +package com.leets.tdd.chat.dto; + +import com.leets.tdd.global.storage.dto.PresignedUploadResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "채팅 이미지 업로드 URL 발급 결과") +public record ChatImagePresignResponse( + @Schema(description = "확정(confirm) 및 IMAGE 메시지 발행 시 사용할 S3 객체 key", + example = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg") + String key, + + @Schema(description = "브라우저가 이미지를 직접 PUT할 URL") + String uploadUrl, + + @Schema(description = "업로드 시 Content-Type 헤더에 그대로 넣어야 하는 값", example = "image/jpeg") + String contentType +) { + public static ChatImagePresignResponse from(PresignedUploadResponse presigned) { + return new ChatImagePresignResponse( + presigned.key(), + presigned.uploadUrl(), + presigned.contentType() + ); + } +} diff --git a/src/main/java/com/leets/tdd/chat/service/ChatImageService.java b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java new file mode 100644 index 0000000..b86870a --- /dev/null +++ b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java @@ -0,0 +1,47 @@ +package com.leets.tdd.chat.service; + +import com.leets.tdd.chat.dto.ChatImageConfirmRequest; +import com.leets.tdd.chat.dto.ChatImagePresignRequest; +import com.leets.tdd.chat.dto.ChatImagePresignResponse; +import com.leets.tdd.global.storage.ImageCategory; +import com.leets.tdd.global.storage.ImageStorageService; +import com.leets.tdd.global.storage.dto.PresignedUploadResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChatImageService { + + private final ImageStorageService imageStorageService; + + /** + * 채팅 이미지 업로드 1단계(발급). 브라우저가 S3에 직접 올릴 key와 Presigned PUT URL을 발급한다. + * DB는 건드리지 않는다(이미지 메시지는 이후 WebSocket 발행 시 저장됨). + + * TODO: 발급 전에 로그인 사용자가 해당 팟(partyId)의 참여자인지 검증해야 한다. + * 배달팟 참여(join) API 구현 후 party_participants 조회로 추가 예정 (#62 선행 조건과 동일). + */ + @Transactional(readOnly = true) + public ChatImagePresignResponse presignUpload(Long partyId, ChatImagePresignRequest request) { + PresignedUploadResponse presigned = + imageStorageService.issueUploadUrl(ImageCategory.CHAT, partyId, request.contentType()); + + return ChatImagePresignResponse.from(presigned); + } + + /** + * 채팅 이미지 업로드 2단계(확정). 브라우저가 S3에 직접 올린 뒤 호출한다. + * 실제로 업로드된 객체의 용량/형식이 기준 안에 있는지 검증한다(위반 시 객체 삭제 + 예외). + * 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 클라이언트가 그 key로 IMAGE 메시지를 + * WebSocket으로 발행하면 기존 채팅 메시지 저장 경로(ChatService.saveMessage)가 처리한다. + + * TODO: confirm 시에도 로그인 사용자가 해당 팟 참여자인지 검증 필요 (join API 후속). + */ + @Transactional(readOnly = true) + public void confirmUpload(Long partyId, ChatImageConfirmRequest request) { + // 용량/형식 기준을 벗어나면 confirmUpload가 ImageException을 던지면서 객체도 함께 지운다. + imageStorageService.confirmUpload(request.key()); + } +} From 15978433de72c98f7eebdaa896334103de7308b2 Mon Sep 17 00:00:00 2001 From: HandoA01 Date: Sun, 26 Jul 2026 15:05:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(presign/confirm,=20=ED=8C=9F=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EA=B2=80=EC=A6=9D=20+=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatImageController.java | 24 +++-- .../tdd/chat/service/ChatImageService.java | 42 +++++--- .../chat/service/ChatImageServiceTest.java | 99 +++++++++++++++++++ 3 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java diff --git a/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java b/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java index 0f84230..412014d 100644 --- a/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java +++ b/src/main/java/com/leets/tdd/chat/controller/ChatImageController.java @@ -5,11 +5,13 @@ import com.leets.tdd.chat.dto.ChatImagePresignResponse; import com.leets.tdd.chat.service.ChatImageService; import com.leets.tdd.global.common.ApiResponse; +import com.leets.tdd.global.jwt.UserPrincipal; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -25,35 +27,39 @@ public class ChatImageController { @Operation( summary = "채팅 이미지 업로드 1단계(업로드 URL 발급)", - description = "채팅 이미지를 올릴 Presigned PUT URL과 key를 발급한다(비공개 버킷). 응답으로 받은 " - + "uploadUrl로 브라우저가 S3에 직접 PUT한 뒤, 그 key로 확정(confirm) API를 호출해야 한다. " - + "허용 형식은 JPEG/PNG/WEBP다. Authorization 헤더에 access token(Bearer)이 필요하다." + description = "채팅 이미지를 올릴 Presigned PUT URL과 key를 발급한다(비공개 버킷). 해당 팟의 참여자만 " + + "발급받을 수 있다. 응답으로 받은 uploadUrl로 브라우저가 S3에 직접 PUT한 뒤, 그 key로 " + + "확정(confirm) API를 호출해야 한다. 허용 형식은 JPEG/PNG/WEBP다. " + + "Authorization 헤더에 access token(Bearer)이 필요하다." ) @SecurityRequirement(name = "bearerAuth") @PostMapping("/presign") public ResponseEntity> presignUpload( @PathVariable Long partyId, + @AuthenticationPrincipal UserPrincipal userPrincipal, @Valid @RequestBody ChatImagePresignRequest request ) { - ChatImagePresignResponse response = chatImageService.presignUpload(partyId, request); + ChatImagePresignResponse response = + chatImageService.presignUpload(partyId, userPrincipal.userId(), request); return ResponseEntity.ok(ApiResponse.success("업로드 URL이 발급되었습니다.", response)); } @Operation( summary = "채팅 이미지 업로드 2단계(업로드 확정)", - description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 서버가 실제로 올라간 객체의 " - + "용량/형식을 확인하고, 기준을 벗어나면 객체를 지우고 실패 처리한다. " - + "확정에 성공하면 클라이언트는 받은 key로 { messageType: \"IMAGE\", imageUrl: key } 형태의 " - + "메시지를 WebSocket(/app/parties/{partyId}/chat)으로 발행해 이미지 메시지를 전송한다. " + description = "브라우저가 S3에 직접 업로드를 마친 뒤 호출한다. 해당 팟의 참여자만 호출할 수 있다. " + + "서버가 실제로 올라간 객체의 용량/형식을 확인하고, 기준을 벗어나면 객체를 지우고 실패 " + + "처리한다. 확정에 성공하면 클라이언트는 받은 key로 { messageType: \"IMAGE\", imageUrl: key } " + + "형태의 메시지를 WebSocket(/app/parties/{partyId}/chat)으로 발행해 이미지 메시지를 전송한다. " + "Authorization 헤더에 access token(Bearer)이 필요하다." ) @SecurityRequirement(name = "bearerAuth") @PostMapping("/confirm") public ResponseEntity> confirmUpload( @PathVariable Long partyId, + @AuthenticationPrincipal UserPrincipal userPrincipal, @Valid @RequestBody ChatImageConfirmRequest request ) { - chatImageService.confirmUpload(partyId, request); + chatImageService.confirmUpload(partyId, userPrincipal.userId(), request); return ResponseEntity.ok(ApiResponse.success("이미지 업로드가 확정되었습니다.", null)); } } diff --git a/src/main/java/com/leets/tdd/chat/service/ChatImageService.java b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java index b86870a..f68cc38 100644 --- a/src/main/java/com/leets/tdd/chat/service/ChatImageService.java +++ b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java @@ -6,6 +6,8 @@ import com.leets.tdd.global.storage.ImageCategory; import com.leets.tdd.global.storage.ImageStorageService; import com.leets.tdd.global.storage.dto.PresignedUploadResponse; +import com.leets.tdd.party.domain.PartyParticipantStatus; +import com.leets.tdd.party.repository.PartyParticipantRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,16 +17,17 @@ public class ChatImageService { private final ImageStorageService imageStorageService; + private final PartyParticipantRepository partyParticipantRepository; /** - * 채팅 이미지 업로드 1단계(발급). 브라우저가 S3에 직접 올릴 key와 Presigned PUT URL을 발급한다. - * DB는 건드리지 않는다(이미지 메시지는 이후 WebSocket 발행 시 저장됨). - - * TODO: 발급 전에 로그인 사용자가 해당 팟(partyId)의 참여자인지 검증해야 한다. - * 배달팟 참여(join) API 구현 후 party_participants 조회로 추가 예정 (#62 선행 조건과 동일). + * 채팅 이미지 업로드 1단계(발급). 발급 전에 로그인 사용자가 해당 팟(partyId)의 참여자(JOINED)인지 + * 검증한다 - key에 적힌 partyId를 권한 근거로 삼지 않기 위해, 요청 사용자가 실제 참여자인지 + * DB로 확인한다. 통과하면 S3에 직접 올릴 key와 Presigned PUT URL을 발급한다(DB는 건드리지 않음). */ @Transactional(readOnly = true) - public ChatImagePresignResponse presignUpload(Long partyId, ChatImagePresignRequest request) { + public ChatImagePresignResponse presignUpload(Long partyId, Long userId, ChatImagePresignRequest request) { + validateParticipant(partyId, userId); + PresignedUploadResponse presigned = imageStorageService.issueUploadUrl(ImageCategory.CHAT, partyId, request.contentType()); @@ -32,16 +35,29 @@ public ChatImagePresignResponse presignUpload(Long partyId, ChatImagePresignRequ } /** - * 채팅 이미지 업로드 2단계(확정). 브라우저가 S3에 직접 올린 뒤 호출한다. - * 실제로 업로드된 객체의 용량/형식이 기준 안에 있는지 검증한다(위반 시 객체 삭제 + 예외). - * 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 클라이언트가 그 key로 IMAGE 메시지를 - * WebSocket으로 발행하면 기존 채팅 메시지 저장 경로(ChatService.saveMessage)가 처리한다. - - * TODO: confirm 시에도 로그인 사용자가 해당 팟 참여자인지 검증 필요 (join API 후속). + * 채팅 이미지 업로드 2단계(확정). 참여자 검증 후, 실제로 업로드된 객체의 용량/형식이 기준 안에 + * 있는지 검증한다(위반 시 객체 삭제 + 예외). 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 + * 클라이언트가 그 key로 IMAGE 메시지를 WebSocket으로 발행하면 기존 ChatService.saveMessage가 + * 저장·브로드캐스트한다. */ @Transactional(readOnly = true) - public void confirmUpload(Long partyId, ChatImageConfirmRequest request) { + public void confirmUpload(Long partyId, Long userId, ChatImageConfirmRequest request) { + validateParticipant(partyId, userId); + // 용량/형식 기준을 벗어나면 confirmUpload가 ImageException을 던지면서 객체도 함께 지운다. imageStorageService.confirmUpload(request.key()); } + + /** + * 로그인 사용자가 해당 팟에 JOINED 상태로 참여 중인지 확인한다. 아니면 접근을 거부한다. + * (배달팟 참여(join) API가 아직 없어 실제 참여자 데이터로의 통과 검증은 join API 머지 후 가능하지만, + * 검증 로직 자체는 여기서 수행한다.) + */ + private void validateParticipant(Long partyId, Long userId) { + boolean isParticipant = partyParticipantRepository + .existsByPartyIdAndUserIdAndStatus(partyId, userId, PartyParticipantStatus.JOINED); + if (!isParticipant) { + throw new IllegalArgumentException("해당 팟의 참여자만 채팅 이미지를 업로드할 수 있습니다."); + } + } } diff --git a/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java new file mode 100644 index 0000000..b93a2bb --- /dev/null +++ b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java @@ -0,0 +1,99 @@ +package com.leets.tdd.chat.service; + +import com.leets.tdd.chat.dto.ChatImageConfirmRequest; +import com.leets.tdd.chat.dto.ChatImagePresignRequest; +import com.leets.tdd.chat.dto.ChatImagePresignResponse; +import com.leets.tdd.global.storage.ImageCategory; +import com.leets.tdd.global.storage.ImageStorageService; +import com.leets.tdd.global.storage.dto.PresignedUploadResponse; +import com.leets.tdd.party.domain.PartyParticipantStatus; +import com.leets.tdd.party.repository.PartyParticipantRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class ChatImageServiceTest { + + @Mock + private ImageStorageService imageStorageService; + + @Mock + private PartyParticipantRepository partyParticipantRepository; + + @InjectMocks + private ChatImageService chatImageService; + + private static final Long PARTY_ID = 1L; + private static final Long USER_ID = 10L; + private static final String CHAT_KEY = "chat/1/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg"; + + // 참여자 여부 stub 헬퍼 + private void givenParticipant(boolean joined) { + when(partyParticipantRepository.existsByPartyIdAndUserIdAndStatus( + PARTY_ID, USER_ID, PartyParticipantStatus.JOINED)) + .thenReturn(joined); + } + + @Test + @DisplayName("팟 참여자가 발급을 요청하면 key와 업로드 URL을 반환한다") + void presign_participant_returnsUrl() { + givenParticipant(true); + when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString())) + .thenReturn(new PresignedUploadResponse(CHAT_KEY, "https://s3.example.com/put", "image/jpeg", 300)); + + ChatImagePresignResponse response = + chatImageService.presignUpload(PARTY_ID, USER_ID, new ChatImagePresignRequest("image/jpeg")); + + assertThat(response.key()).isEqualTo(CHAT_KEY); + assertThat(response.uploadUrl()).isEqualTo("https://s3.example.com/put"); + verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString()); + } + + @Test + @DisplayName("팟 참여자가 아니면 발급이 거부되고 S3를 호출하지 않는다") + void presign_nonParticipant_throws() { + givenParticipant(false); + + assertThatThrownBy(() -> + chatImageService.presignUpload(PARTY_ID, USER_ID, new ChatImagePresignRequest("image/jpeg"))) + .isInstanceOf(IllegalArgumentException.class); + + verify(imageStorageService, never()).issueUploadUrl(any(), anyLong(), anyString()); + } + + @Test + @DisplayName("팟 참여자가 확정을 요청하면 업로드 객체를 검증한다") + void confirm_participant_verifiesUpload() { + givenParticipant(true); + + chatImageService.confirmUpload(PARTY_ID, USER_ID, new ChatImageConfirmRequest(CHAT_KEY)); + + verify(imageStorageService).confirmUpload(CHAT_KEY); + } + + @Test + @DisplayName("팟 참여자가 아니면 확정이 거부되고 검증을 호출하지 않는다") + void confirm_nonParticipant_throws() { + givenParticipant(false); + + assertThatThrownBy(() -> + chatImageService.confirmUpload(PARTY_ID, USER_ID, new ChatImageConfirmRequest(CHAT_KEY))) + .isInstanceOf(IllegalArgumentException.class); + + verify(imageStorageService, never()).confirmUpload(anyString()); + } +} From 0dfa6d865a03450b7835b2aa351e00ab0004e770 Mon Sep 17 00:00:00 2001 From: HandoA01 Date: Mon, 27 Jul 2026 15:40:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20confirm=20=EC=8B=9C=20key=EA=B0=80=20?= =?UTF-8?q?=ED=95=B4=EB=8B=B9=20=ED=8C=9F=20=EA=B2=BD=EB=A1=9C=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tdd/chat/service/ChatImageService.java | 24 +++++++++++++++---- .../chat/service/ChatImageServiceTest.java | 14 +++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/leets/tdd/chat/service/ChatImageService.java b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java index f68cc38..0ecc561 100644 --- a/src/main/java/com/leets/tdd/chat/service/ChatImageService.java +++ b/src/main/java/com/leets/tdd/chat/service/ChatImageService.java @@ -35,14 +35,18 @@ public ChatImagePresignResponse presignUpload(Long partyId, Long userId, ChatIma } /** - * 채팅 이미지 업로드 2단계(확정). 참여자 검증 후, 실제로 업로드된 객체의 용량/형식이 기준 안에 - * 있는지 검증한다(위반 시 객체 삭제 + 예외). 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 - * 클라이언트가 그 key로 IMAGE 메시지를 WebSocket으로 발행하면 기존 ChatService.saveMessage가 - * 저장·브로드캐스트한다. + * 채팅 이미지 업로드 2단계(확정). 참여자 검증 + key가 해당 팟(chat/{partyId}/)의 것인지 검증한 뒤, + * 실제로 업로드된 객체의 용량/형식이 기준 안에 있는지 검증한다(위반 시 객체 삭제 + 예외). + * 검증만 하고 메시지는 저장하지 않는다 - 확정 통과 후 클라이언트가 그 key로 IMAGE 메시지를 + * WebSocket으로 발행하면 기존 ChatService.saveMessage가 저장·브로드캐스트한다. */ @Transactional(readOnly = true) public void confirmUpload(Long partyId, Long userId, ChatImageConfirmRequest request) { validateParticipant(partyId, userId); + // 요청으로 받은 key가 이 팟의 채팅 이미지 경로(chat/{partyId}/)인지 확인한다. + // 다른 팟(예: 두 팟에 모두 참여한 사용자가 A 팟 key를 B 팟으로 확정)에서 발급된 key를 + // 넘겨 남의 이미지를 확정하는 것을 막는다. + validateKeyBelongsToParty(partyId, request.key()); // 용량/형식 기준을 벗어나면 confirmUpload가 ImageException을 던지면서 객체도 함께 지운다. imageStorageService.confirmUpload(request.key()); @@ -60,4 +64,16 @@ private void validateParticipant(Long partyId, Long userId) { throw new IllegalArgumentException("해당 팟의 참여자만 채팅 이미지를 업로드할 수 있습니다."); } } + + /** + * 요청으로 받은 key가 해당 팟의 채팅 이미지 경로(chat/{partyId}/)로 시작하는지 확인한다. + * 공통 모듈은 key의 접두사를 버킷 라우팅에만 쓰고 partyId 소유권은 검증하지 않으므로, + * 도메인에서 "이 key가 이 팟의 것인지"를 확인해 교차 팟 확정을 차단한다. + */ + private void validateKeyBelongsToParty(Long partyId, String key) { + String expectedPrefix = ImageCategory.CHAT.getPrefix() + "/" + partyId + "/"; + if (key == null || !key.startsWith(expectedPrefix)) { + throw new IllegalArgumentException("해당 팟의 이미지 key가 아닙니다."); + } + } } diff --git a/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java index b93a2bb..74ecdec 100644 --- a/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java +++ b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java @@ -96,4 +96,18 @@ void confirm_nonParticipant_throws() { verify(imageStorageService, never()).confirmUpload(anyString()); } + + @Test + @DisplayName("참여자여도 다른 팟의 key로 확정하면 거부되고 검증을 호출하지 않는다") + void confirm_keyFromAnotherParty_throws() { + givenParticipant(true); // party 1의 참여자는 맞음 + // 하지만 key는 다른 팟(chat/999/)의 것 → 거부돼야 함 + String otherPartyKey = "chat/999/1f0a2c4e-1b3d-4f5a-8c9d-0e1f2a3b4c5d.jpg"; + + assertThatThrownBy(() -> + chatImageService.confirmUpload(PARTY_ID, USER_ID, new ChatImageConfirmRequest(otherPartyKey))) + .isInstanceOf(IllegalArgumentException.class); + + verify(imageStorageService, never()).confirmUpload(anyString()); + } } From 7a270cd96ac2814d26ce06ab3d261a6c6edc5aeb Mon Sep 17 00:00:00 2001 From: HandoA01 Date: Mon, 27 Jul 2026 16:05:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20confirm=20key=20=ED=8C=9F=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80=20+=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20contentType=20=EC=A0=95=ED=99=95=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/leets/tdd/chat/service/ChatImageServiceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java index 74ecdec..5ccfe60 100644 --- a/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java +++ b/src/test/java/com/leets/tdd/chat/service/ChatImageServiceTest.java @@ -52,7 +52,7 @@ private void givenParticipant(boolean joined) { @DisplayName("팟 참여자가 발급을 요청하면 key와 업로드 URL을 반환한다") void presign_participant_returnsUrl() { givenParticipant(true); - when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString())) + when(imageStorageService.issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), eq("image/jpeg"))) .thenReturn(new PresignedUploadResponse(CHAT_KEY, "https://s3.example.com/put", "image/jpeg", 300)); ChatImagePresignResponse response = @@ -60,7 +60,7 @@ void presign_participant_returnsUrl() { assertThat(response.key()).isEqualTo(CHAT_KEY); assertThat(response.uploadUrl()).isEqualTo("https://s3.example.com/put"); - verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), anyString()); + verify(imageStorageService).issueUploadUrl(eq(ImageCategory.CHAT), eq(PARTY_ID), eq("image/jpeg")); } @Test