diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index 4900e8e4..ce1df337 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -363,16 +363,6 @@ interface TournamentApi { ), ], ), - ApiResponse( - responseCode = "403", - description = "권한 없음 (GUEST 권한으로 접근 불가 · MEMBER 필요 — code: TOURNAMENT-036)", - content = [ - Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = Schema(implementation = ApiResponseBody::class), - ), - ], - ), ApiResponse( responseCode = "409", description = "탈퇴한 계정 (JWT 는 아직 유효하나 계정이 탈퇴 상태) — code: USER-003", diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index 60709782..8c05a71a 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -103,7 +103,6 @@ class TournamentApiExamples( ), ) unauthorized() - add(TournamentException.guestCannotCreateTournament(), name = "게스트의 토너먼트 생성 거부 (회원 전용)") add(UserException.deletedUser(), name = "탈퇴한 유저") } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index 9f7127a1..5b0acbb6 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -56,6 +56,10 @@ enum class TournamentErrorCode( // 036 은 게스트 권한 정리(#339)에서 추가됐다. 토너먼트 생성은 회원 전용이 되고, 생성된 토너먼트에 // 아이템을 담는 것과 플레이는 게스트에게 그대로 열려 있다 — "게스트는 소비만, 생산은 회원만". + // + // 지금은 아무도 던지지 않는다. 클라이언트가 이 code 를 처리할 때까지 게이트를 임시로 걷었다(#965). + // 엔트리를 남겨 두는 것은 곧 되살릴 것이기 때문이고, 번호는 append-only 라 어차피 재사용하지 않는다. + // 게이트를 되살리는 자리는 TournamentService.rejectIfDeleted 주석에 적혀 있다. GUEST_CANNOT_CREATE_TOURNAMENT("TOURNAMENT-036", ErrorCategory.FORBIDDEN, "토너먼트 만들기는 회원만 이용할 수 있어요."), // 037 도 #339. 차감 주체는 토너먼트 오너지만 이 응답은 참여자(게스트 포함) 누구나 받을 수 있으므로, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt index b088e9da..73443930 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt @@ -102,6 +102,8 @@ class TournamentException private constructor( // 토너먼트 만들기는 회원 전용(#339) — 게스트(인증은 됐으나 회원 아님)가 정상 요청으로 닿을 수 있는 계약 응답이라 커스텀 예외(403). // Security 에서 MEMBER 만 허용하면 detail 없는 권한 없음 403 으로 떨어져 "회원 전용" 사유를 못 전달하므로, // authenticated() 로 통과시킨 뒤 서비스가 이 예외로 막는다(WishException.guestCannotUseWishlist 와 같은 패턴). + // + // 현재 호출부가 없다. 클라이언트 대응 전까지 게이트를 임시로 걷은 상태다(#965). 되살릴 때 그대로 쓴다. fun guestCannotCreateTournament(): TournamentException = TournamentException(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT) } } diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index 5e8b0575..b58ddfc4 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -38,7 +38,6 @@ import com.depromeet.piki.tournament.service.dto.TournamentItemDetail import com.depromeet.piki.tournament.service.dto.StartResult import com.depromeet.piki.tournament.service.dto.TournamentStartResult import com.depromeet.piki.tournament.service.dto.TournamentSummary -import com.depromeet.piki.user.domain.IdentityType import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.wishlist.repository.WishRepository @@ -60,22 +59,21 @@ class TournamentService( private val wishRepository: WishRepository, private val eventPublisher: ApplicationEventPublisher, ) { - // 토너먼트 만들기는 회원 전용(#339). 게스트는 이미 만들어진 토너먼트에 참여·아이템 추가·플레이만 한다. - // 게스트 계정은 입력 없이 무한 발급되므로(POST /auth/guest), 비용이 드는 행위의 소유자는 항상 회원이어야 - // 계정을 갈아타며 한도를 리셋하는 우회가 성립하지 않는다 — 소셜 계정 생성 비용이 그 우회를 막는다. - // 인증 principal 은 userId 뿐이라 identityType 은 조회로 확인한다. + // 탈퇴(tombstone) 계정의 토너먼트 생성을 막는다. anonymize 는 닉네임·프로필만 비우고 행은 남기므로, + // 탈퇴 시 토큰 무효화가 부분 실패한 창에서 죽은 계정이 토너먼트를 만들 수 있다 + // (위시가 findActiveById 로 막는 것과 같은 사유, #691). // // users 행 존재는 강제하지 않는다(findActiveById 가 아니라 findById + Elvis) — 인증만 되면 행 없이도 호출되던 - // 기존 계약을 이 게이트가 404 로 바꾸지 않기 위해서다(FCM 토큰 등록의 rejectIfWithdrawnForUpdate 와 같은 결). - // 게이트에 구멍을 내지 않는다: 게스트는 발급이 곧 users 행 생성이라(UserService.createGuest) 반드시 행이 있고, - // 토큰은 우리가 서명하므로 "행 없는 유효 토큰" 은 정상 경로에서 만들어지지 않는다. - // 탈퇴(tombstone) 계정도 막는다 — anonymize 는 닉네임·프로필만 비우고 identityType 은 MEMBER 로 남기므로, - // identityType 만 보면 죽은 계정이 토너먼트를 만든다. 탈퇴 시 토큰 무효화가 부분 실패한 창에서 실제로 닿을 수 있다 - // (위시가 findActiveById 로 막는 것과 같은 사유, #691). - private fun requireMember(userId: UUID) { + // 기존 계약을 이 가드가 404 로 바꾸지 않기 위해서다(FCM 토큰 등록의 rejectIfWithdrawnForUpdate 와 같은 결). + // + // 회원 전용 게이트(#339)가 여기 함께 있었으나 클라이언트 대응 전까지 임시로 걷어냈다(#965). 그래서 게스트도 + // 다시 토너먼트를 만들 수 있고, 그 토너먼트의 아이템 등록은 오너인 게스트 몫에서 깎인다. 게스트 계정은 + // 무한 발급되므로(POST /auth/guest) 계정별 한도(ItemQuotaGuard)는 이 창 동안 게스트에 대해 실효가 없고, + // 남는 방어선은 전역 가용량 상한 하나다. 재적용은 아래 한 줄을 되살리면 된다(code·예외는 남겨 뒀다): + // if (user.identityType != IdentityType.MEMBER) throw TournamentException.guestCannotCreateTournament() + private fun rejectIfDeleted(userId: UUID) { val user = userRepository.findById(userId) ?: return user.deletedAt?.let { throw UserException.deletedUser() } - if (user.identityType != IdentityType.MEMBER) throw TournamentException.guestCannotCreateTournament() } @Transactional @@ -83,7 +81,7 @@ class TournamentService( userId: UUID, command: CreateTournament, ): CreateTournamentResult { - requireMember(userId) + rejectIfDeleted(userId) val inviteCode = generateUniqueInviteCode() val inviteExpiresAt = LocalDateTime .now() diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 33a209f3..948fee00 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -135,7 +135,10 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } @Test - fun `POST tournaments 는 게스트가 요청하면 403 과 GUEST_CANNOT_CREATE_TOURNAMENT code 를 반환한다`() { + fun `POST tournaments 는 게스트가 요청해도 생성된다 (회원 전용 게이트 임시 해제)`() { + // 회원 전용 게이트(#339)는 클라이언트가 403(TOURNAMENT-036)을 처리할 때까지 임시로 걷어 뒀다(#965). + // 그전까지의 계약("게스트는 403")을 뒤집은 단언이라, 게이트를 되살리면 이 테스트가 정확히 깨져 + // 되돌릴 자리를 알려 준다. code·예외 팩토리는 그때 그대로 쓰려고 남겨 뒀다. val mockMvc = buildMockMvc() val guestId = UUID.randomUUID() userJpaRepository.save( @@ -149,13 +152,12 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .header(HttpHeaders.AUTHORIZATION, "Bearer ${jwtProvider.generateAccessToken(guestId, IdentityType.GUEST)}") .contentType(MediaType.APPLICATION_JSON) .content("""{"name":"게스트 토너먼트"}"""), - ).andExpect(status().isForbidden) - .andExpect(jsonPath("$.code").value(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT.code)) - .andExpect(jsonPath("$.detail").value(TournamentErrorCode.GUEST_CANNOT_CREATE_TOURNAMENT.message)) - .andExpect(jsonPath("$.data").value(nullValue())) + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.data.tournamentId").isNumber) + .andExpect(jsonPath("$.data.inviteCode").isString) - // 거부가 응답으로만 끝나지 않고 실제로 아무것도 만들지 않았는지 확인한다 — 게이트가 saveTournament 앞에 선다. - assertEquals(tournamentsBefore, tournamentJpaRepository.count()) + // 응답만 201 이고 실제로는 안 만들어지는 경우를 배제한다. + assertEquals(tournamentsBefore + 1, tournamentJpaRepository.count()) } @Test