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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ class TournamentApiExamples(
),
)
unauthorized()
add(TournamentException.guestCannotCreateTournament(), name = "게스트의 토너먼트 생성 거부 (회원 전용)")
add(UserException.deletedUser(), name = "탈퇴한 유저")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. 차감 주체는 토너먼트 오너지만 이 응답은 참여자(게스트 포함) 누구나 받을 수 있으므로,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,30 +59,29 @@ 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
fun create(
userId: UUID,
command: CreateTournament,
): CreateTournamentResult {
requireMember(userId)
rejectIfDeleted(userId)
val inviteCode = generateUniqueInviteCode()
val inviteExpiresAt = LocalDateTime
.now()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading