diff --git a/backend/src/main/java/com/shadowfit/repository/exercise/SessionRepository.java b/backend/src/main/java/com/shadowfit/repository/exercise/SessionRepository.java index 6f183242..ae4df0de 100644 --- a/backend/src/main/java/com/shadowfit/repository/exercise/SessionRepository.java +++ b/backend/src/main/java/com/shadowfit/repository/exercise/SessionRepository.java @@ -25,9 +25,11 @@ public interface SessionRepository extends JpaRepository { Optional findSessionWithExerciseByIdAndMemberId(@Param("sessionId") Long sessionId, @Param("memberId") Long memberId); - // 개별 세션 삭제(deleteSession) 전용 — exercise fetch join 불필요, 소유권만 WHERE절로 확인. + // 세션 삭제·종료(deleteSession·endSession) — exercise fetch join 불필요, 소유권만 WHERE절로 확인. Optional findByIdAndMemberId(Long sessionId, Long memberId); + boolean existsByIdAndMemberId(Long sessionId, Long memberId); + // 서킷브레이커 OPEN 자동 재부착(REATTACH_ANALYSIS) 전용 — 위 findSessionWithExerciseByIdAndMemberId // 와 달리 소유권(memberId) 조건이 없다. 호출 경로가 사용자 요청이 아니라 시스템(서킷브레이커 // 이벤트 → 아웃박스 발행기)이라 "요청자"가 없다 — 애초에 대조할 currentMemberId 가 없다. diff --git a/backend/src/main/java/com/shadowfit/repository/group/GroupInvitationRepository.java b/backend/src/main/java/com/shadowfit/repository/group/GroupInvitationRepository.java index 7d824ae0..e8dfdab1 100644 --- a/backend/src/main/java/com/shadowfit/repository/group/GroupInvitationRepository.java +++ b/backend/src/main/java/com/shadowfit/repository/group/GroupInvitationRepository.java @@ -14,6 +14,9 @@ public interface GroupInvitationRepository extends JpaRepository findByIdAndInviteeId(Long invitationId, Long inviteeId); + // GET /invitations/mine. List findAllByInviteeIdAndStatus(Long inviteeId, InvitationStatus status); diff --git a/backend/src/main/java/com/shadowfit/service/exercise/SessionFeedbackQueryService.java b/backend/src/main/java/com/shadowfit/service/exercise/SessionFeedbackQueryService.java index 3691bce4..8034b7b7 100644 --- a/backend/src/main/java/com/shadowfit/service/exercise/SessionFeedbackQueryService.java +++ b/backend/src/main/java/com/shadowfit/service/exercise/SessionFeedbackQueryService.java @@ -4,7 +4,6 @@ import com.shadowfit.dto.exercises.feedback.SessionFeedbackSummaryDto; import com.shadowfit.global.error.BusinessException; import com.shadowfit.global.error.ErrorCode; -import com.shadowfit.model.exercise.Session; import com.shadowfit.repository.exercise.SessionFeedbackLogRepository; import com.shadowfit.repository.exercise.SessionRepository; import lombok.RequiredArgsConstructor; @@ -46,11 +45,11 @@ public SessionFeedbackSummaryDto getSummary(Long sessionId, Long currentMemberId return new SessionFeedbackSummaryDto(sessionId, total, buckets); } + // 없거나 남의 세션이면 똑같이 404 — 존재 여부 비공개(decisions/resource-ownership-403-vs-404.md 후보 C). + // 세션 행 자체는 안 쓰므로 엔티티를 싣지 않고 존재만 본다. private void ensureOwnership(Long sessionId, Long currentMemberId) { - Session session = sessionRepository.findById(sessionId) - .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND)); - if (!session.getMember().getId().equals(currentMemberId)) { - throw new BusinessException(ErrorCode.ACCESS_DENIED); + if (!sessionRepository.existsByIdAndMemberId(sessionId, currentMemberId)) { + throw new BusinessException(ErrorCode.SESSION_NOT_FOUND); } } diff --git a/backend/src/main/java/com/shadowfit/service/exercise/SessionService.java b/backend/src/main/java/com/shadowfit/service/exercise/SessionService.java index ee99f840..2a07fdb4 100644 --- a/backend/src/main/java/com/shadowfit/service/exercise/SessionService.java +++ b/backend/src/main/java/com/shadowfit/service/exercise/SessionService.java @@ -294,18 +294,15 @@ public void completeSession(SessionCompleteRequest request) { * - endTime 만 즉시 기록. 통계 갱신(totalReps/avgSync) 은 AI 의 CompleteAnalysis 콜백이 별도 처리 * - AI 로의 gRPC 는 이 경로에서 일어나지 않는다. OutboxPublisher 가 행을 집어 송신하므로 * 요청 스레드는 외부 호출을 기다리지 않고, 송신이 실패해도 행이 남아 재시도된다 - * - 본인 세션이 아니면 ACCESS_DENIED, 이미 종료된 세션이면 멱등 (변경 없음, 200 OK) + * - 없거나 남의 세션이면 SESSION_NOT_FOUND(404) — 존재 여부 비공개, 이미 종료된 세션이면 멱등 (변경 없음, 200 OK) + * (decisions/resource-ownership-403-vs-404.md 후보 C: 개인 소유 리소스는 소유권을 WHERE 에) * - 통보가 끝내 전달되지 못하면: SessionTimeoutScheduler 가 여전히 safety net (IN_PROGRESS → FAILED) */ @Transactional public void endSession(Long sessionId, Long currentMemberId) { - Session session = sessionRepository.findById(sessionId) + Session session = sessionRepository.findByIdAndMemberId(sessionId, currentMemberId) .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND)); - if (!session.getMember().getId().equals(currentMemberId)) { - throw new BusinessException(ErrorCode.ACCESS_DENIED); - } - // 멱등: 이미 endTime 기록된 세션은 변경 없음 (AI 재호출도 안 함) if (!session.markEnded(LocalDateTime.now())) { return; diff --git a/backend/src/main/java/com/shadowfit/service/group/GroupInvitationService.java b/backend/src/main/java/com/shadowfit/service/group/GroupInvitationService.java index b8b64392..45b8e7f5 100644 --- a/backend/src/main/java/com/shadowfit/service/group/GroupInvitationService.java +++ b/backend/src/main/java/com/shadowfit/service/group/GroupInvitationService.java @@ -84,12 +84,10 @@ public void decline(Long invitationId, Long inviteeId) { } private GroupInvitation getRespondableInvitation(Long invitationId, Long inviteeId) { - GroupInvitation invitation = groupInvitationRepository.findById(invitationId) + // 초대 한 건은 초대받은 한 사람의 것 — 남의 초대는 없는 것과 같다 + // (decisions/resource-ownership-403-vs-404.md 후보 C). + GroupInvitation invitation = groupInvitationRepository.findByIdAndInviteeId(invitationId, inviteeId) .orElseThrow(() -> new BusinessException(ErrorCode.INVITATION_NOT_FOUND)); - - if (!invitation.getInvitee().getId().equals(inviteeId)) { - throw new BusinessException(ErrorCode.ACCESS_DENIED); - } if (invitation.getStatus() != InvitationStatus.PENDING) { throw new BusinessException(ErrorCode.INVITATION_ALREADY_RESPONDED); } diff --git a/backend/src/test/java/com/shadowfit/integration/GroupInvitationControllerIntegrationTest.java b/backend/src/test/java/com/shadowfit/integration/GroupInvitationControllerIntegrationTest.java index 187008bd..a197b9ac 100644 --- a/backend/src/test/java/com/shadowfit/integration/GroupInvitationControllerIntegrationTest.java +++ b/backend/src/test/java/com/shadowfit/integration/GroupInvitationControllerIntegrationTest.java @@ -142,13 +142,13 @@ void accept_joinsGroupAndRecordsEvent() throws Exception { } @Test - @DisplayName("초대 수락 — 초대받은 사람이 아니면 403") - void accept_wrongInvitee_returns403() throws Exception { + @DisplayName("초대 수락 — 초대받은 사람이 아니면 404 (없는 초대와 같은 답)") + void accept_wrongInvitee_returns404() throws Exception { GroupInvitation invitation = sendInvitation(); mockMvc.perform(post("/invitations/" + invitation.getId() + "/accept") .header("Authorization", "Bearer " + ownerToken)) - .andExpect(status().isForbidden()); + .andExpect(status().isNotFound()); } @Test diff --git a/backend/src/test/java/com/shadowfit/integration/SessionOwnershipNotFoundTest.java b/backend/src/test/java/com/shadowfit/integration/SessionOwnershipNotFoundTest.java new file mode 100644 index 00000000..2b0c4d96 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/integration/SessionOwnershipNotFoundTest.java @@ -0,0 +1,123 @@ +package com.shadowfit.integration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.shadowfit.dto.login.CustomUserInfoDto; +import com.shadowfit.global.security.jwt.JwtUtil; +import com.shadowfit.model.exercise.Category; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.SelectedPersona; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.CategoryRepository; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 남의 세션과 없는 세션이 같은 답을 받는지 검증한다 + * (decisions/resource-ownership-403-vs-404.md 후보 C — 개인 소유 리소스는 404). + * + *

예전엔 이 세 엔드포인트만 조회 후 비교라 남의 세션에 403 을 줬다 — id 순차 대입으로 «존재하는 + * 세션 id» 를 알아낼 수 있었다. 서비스 단위 테스트는 목(mock)이라 derived query + * ({@code findByIdAndMemberId}·{@code existsByIdAndMemberId})가 실제로 소유자를 거르는지 못 본다 — + * 그래서 실제 DB 로 HTTP 까지 태운다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@DisplayName("남의 세션 — 없는 세션과 같은 404") +class SessionOwnershipNotFoundTest { + + private static final long MISSING_ID = 99_999_999L; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtUtil jwtUtil; + @Autowired private MemberRepository memberRepository; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private SessionRepository sessionRepository; + + private Session othersSession; + private String strangerToken; + + @BeforeEach + void setUp() { + Member owner = memberRepository.saveAndFlush(Member.builder() + .email("owner-404@test.com").username("owner").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + Member stranger = memberRepository.saveAndFlush(Member.builder() + .email("stranger-404@test.com").username("stranger").password("dummy") + .selectedPersona(SelectedPersona.BEGINNER).role(UserRole.USER).build()); + Category category = categoryRepository.save(Category.builder().name("LOWER").build()); + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(category).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")).syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + othersSession = sessionRepository.saveAndFlush(Session.builder() + .member(owner).exercise(exercise) + .startTime(LocalDateTime.now().minusMinutes(10)) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + strangerToken = jwtUtil.createAccessToken(CustomUserInfoDto.builder() + .email(stranger.getEmail()).role(stranger.getRole()).build()); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"end", "feedbacks", "feedback-summary"}) + @DisplayName("남의 세션 → 404, 없는 세션과 본문이 같다") + void othersSession_sameAsMissing(String endpoint) throws Exception { + String others = mockMvc.perform(request(endpoint, othersSession.getId())) + .andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + String missing = mockMvc.perform(request(endpoint, MISSING_ID)) + .andExpect(status().isNotFound()) + .andReturn().getResponse().getContentAsString(); + + // timestamp 만 다르다 — 나머지가 같아야 응답으로 둘을 못 가른다. + assertThat(withoutTimestamp(others)).isEqualTo(withoutTimestamp(missing)); + } + + @Test + @DisplayName("남의 종료 요청은 세션을 건드리지 않는다") + void othersEnd_doesNotTouchSession() throws Exception { + mockMvc.perform(request("end", othersSession.getId())).andExpect(status().isNotFound()); + + assertThat(sessionRepository.findById(othersSession.getId()).orElseThrow().getEndTime()).isNull(); + } + + private MockHttpServletRequestBuilder request(String endpoint, long sessionId) { + String path = "/sessions/" + sessionId + "/" + endpoint; + MockHttpServletRequestBuilder builder = endpoint.equals("end") ? patch(path) : get(path); + return builder.header("Authorization", "Bearer " + strangerToken); + } + + // timestamp 는 직렬화 형식(문자열/배열)이 설정에 따라 달라서 정규식 대신 JSON 으로 지운다. + private JsonNode withoutTimestamp(String body) throws Exception { + ObjectNode node = (ObjectNode) objectMapper.readTree(body); + node.remove("timestamp"); + return node; + } +} diff --git a/backend/src/test/java/com/shadowfit/service/exercise/SessionFeedbackQueryServiceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/SessionFeedbackQueryServiceTest.java index f44e7757..b716beb7 100644 --- a/backend/src/test/java/com/shadowfit/service/exercise/SessionFeedbackQueryServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/exercise/SessionFeedbackQueryServiceTest.java @@ -23,7 +23,6 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -59,7 +58,7 @@ void setUp() { @Test @DisplayName("getEvents — 본인 세션이면 발생시각 순 이벤트 목록 반환") void getEvents_success() { - when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + when(sessionRepository.existsByIdAndMemberId(SESSION_ID, OWNER_ID)).thenReturn(true); SessionFeedbackLog log = SessionFeedbackLog.builder() .id(1L).session(session).feedbackType(FeedbackType.KNEE_OUT) .repNumber(1).occurredAt(LocalDateTime.now()).build(); @@ -74,7 +73,7 @@ void getEvents_success() { @Test @DisplayName("getEvents — 세션이 없으면 SESSION_NOT_FOUND") void getEvents_sessionNotFound_throws() { - when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.empty()); + when(sessionRepository.existsByIdAndMemberId(SESSION_ID, OWNER_ID)).thenReturn(false); assertThatThrownBy(() -> service.getEvents(SESSION_ID, OWNER_ID)) .isInstanceOf(BusinessException.class) @@ -83,20 +82,20 @@ void getEvents_sessionNotFound_throws() { } @Test - @DisplayName("getEvents — 본인 세션이 아니면 ACCESS_DENIED") + @DisplayName("getEvents — 본인 세션이 아니면 SESSION_NOT_FOUND (존재 여부 비공개)") void getEvents_notOwner_throws() { - when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + when(sessionRepository.existsByIdAndMemberId(SESSION_ID, 999L)).thenReturn(false); assertThatThrownBy(() -> service.getEvents(SESSION_ID, 999L)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.ACCESS_DENIED); + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); } @Test @DisplayName("getSummary — 타입별 카운트·통계를 총합과 함께 반환") void getSummary_success() { - when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + when(sessionRepository.existsByIdAndMemberId(SESSION_ID, OWNER_ID)).thenReturn(true); SessionFeedbackLogRepository.TypeStats stat = mock(SessionFeedbackLogRepository.TypeStats.class); when(stat.getFeedbackType()).thenReturn(FeedbackType.KNEE_OUT); when(stat.getCount()).thenReturn(3L); @@ -113,13 +112,13 @@ void getSummary_success() { } @Test - @DisplayName("getSummary — 본인 세션이 아니면 ACCESS_DENIED (getEvents와 동일 소유권 검증 공유)") + @DisplayName("getSummary — 본인 세션이 아니면 SESSION_NOT_FOUND (getEvents와 동일 소유권 검증 공유)") void getSummary_notOwner_throws() { - when(sessionRepository.findById(SESSION_ID)).thenReturn(Optional.of(session)); + when(sessionRepository.existsByIdAndMemberId(SESSION_ID, 999L)).thenReturn(false); assertThatThrownBy(() -> service.getSummary(SESSION_ID, 999L)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.ACCESS_DENIED); + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); } } diff --git a/backend/src/test/java/com/shadowfit/service/exercise/SessionServiceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/SessionServiceTest.java index 0e1161b9..d4c8720d 100644 --- a/backend/src/test/java/com/shadowfit/service/exercise/SessionServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/exercise/SessionServiceTest.java @@ -300,14 +300,16 @@ void endSession_self_setsEndTime() { } @Test - @DisplayName("본인 세션이 아니면 ACCESS_DENIED") + @DisplayName("본인 세션이 아니면 SESSION_NOT_FOUND — 없는 세션과 같은 답 (존재 여부 비공개)") void endSession_notOwner_throws() { Session session = inProgressSession(); assertThatThrownBy(() -> sessionService.endSession(session.getId(), 999999L)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.ACCESS_DENIED); + .isEqualTo(ErrorCode.SESSION_NOT_FOUND); + assertThat(sessionRepository.findById(session.getId()).orElseThrow().getEndTime()) + .as("남의 요청으로 종료 시각이 찍히면 안 된다").isNull(); } @Test diff --git a/backend/src/test/java/com/shadowfit/service/group/GroupInvitationServiceTest.java b/backend/src/test/java/com/shadowfit/service/group/GroupInvitationServiceTest.java index 72cda346..edd423e3 100644 --- a/backend/src/test/java/com/shadowfit/service/group/GroupInvitationServiceTest.java +++ b/backend/src/test/java/com/shadowfit/service/group/GroupInvitationServiceTest.java @@ -135,15 +135,14 @@ void listMyInvitations_returnsPendingOnly() { } @Test - @DisplayName("accept — 초대받은 사람이 아니면 ACCESS_DENIED") + @DisplayName("accept — 초대받은 사람이 아니면 INVITATION_NOT_FOUND (남의 초대는 없는 것과 같다)") void accept_wrongInvitee_throws() { - GroupInvitation invitation = GroupInvitation.builder().group(group).inviter(inviter).invitee(invitee).build(); - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.of(invitation)); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, 999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.accept(INVITATION_ID, 999L)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.ACCESS_DENIED); + .isEqualTo(ErrorCode.INVITATION_NOT_FOUND); } @Test @@ -151,7 +150,7 @@ void accept_wrongInvitee_throws() { void accept_alreadyResponded_throws() { GroupInvitation invitation = GroupInvitation.builder().group(group).inviter(inviter).invitee(invitee).build(); invitation.accept(); - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.of(invitation)); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, INVITEE_ID)).thenReturn(Optional.of(invitation)); assertThatThrownBy(() -> service.accept(INVITATION_ID, INVITEE_ID)) .isInstanceOf(BusinessException.class) @@ -162,7 +161,7 @@ void accept_alreadyResponded_throws() { @Test @DisplayName("accept — 존재하지 않는 초대면 INVITATION_NOT_FOUND") void accept_unknownInvitation_throws() { - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.empty()); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, INVITEE_ID)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.accept(INVITATION_ID, INVITEE_ID)) .isInstanceOf(BusinessException.class) @@ -174,7 +173,7 @@ void accept_unknownInvitation_throws() { @DisplayName("accept — 성공 시 초대가 ACCEPTED 가 되고, 그룹 행을 잠근 채 GroupService.admit 에 가입을 맡긴다") void accept_success_marksAcceptedAndAdmitsUnderLock() { GroupInvitation invitation = GroupInvitation.builder().group(group).inviter(inviter).invitee(invitee).build(); - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.of(invitation)); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, INVITEE_ID)).thenReturn(Optional.of(invitation)); when(groupRepository.findByIdForUpdate(GROUP_ID)).thenReturn(Optional.of(group)); service.accept(INVITATION_ID, INVITEE_ID); @@ -190,7 +189,7 @@ void accept_success_marksAcceptedAndAdmitsUnderLock() { @DisplayName("decline — 성공 시 상태만 바뀌고 이벤트는 발행하지 않는다") void decline_success_doesNotPublishEvent() { GroupInvitation invitation = GroupInvitation.builder().group(group).inviter(inviter).invitee(invitee).build(); - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.of(invitation)); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, INVITEE_ID)).thenReturn(Optional.of(invitation)); service.decline(INVITATION_ID, INVITEE_ID); @@ -199,15 +198,14 @@ void decline_success_doesNotPublishEvent() { } @Test - @DisplayName("decline — 초대받은 사람이 아니면 ACCESS_DENIED") + @DisplayName("decline — 초대받은 사람이 아니면 INVITATION_NOT_FOUND (남의 초대는 없는 것과 같다)") void decline_wrongInvitee_throws() { - GroupInvitation invitation = GroupInvitation.builder().group(group).inviter(inviter).invitee(invitee).build(); - when(groupInvitationRepository.findById(INVITATION_ID)).thenReturn(Optional.of(invitation)); + when(groupInvitationRepository.findByIdAndInviteeId(INVITATION_ID, 999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.decline(INVITATION_ID, 999L)) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getErrorCode()) - .isEqualTo(ErrorCode.ACCESS_DENIED); + .isEqualTo(ErrorCode.INVITATION_NOT_FOUND); } private void stubActiveInviter() { diff --git a/docs/17-error-codes.md b/docs/17-error-codes.md index 3375e70f..c538428c 100644 --- a/docs/17-error-codes.md +++ b/docs/17-error-codes.md @@ -164,13 +164,18 @@ if (!memberRepository.existsByEmail(email)) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } -// 운동 세션 조회 실패 -Session session = sessionRepository.findById(sessionId) +// 내 세션 조회 — 소유권을 WHERE 에 넣는다. 없는 것·남의 것 둘 다 같은 404 +Session session = sessionRepository.findByIdAndMemberId(sessionId, currentMemberId) .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND)); ``` > 외부 API 응답 매핑 시에도 같은 패턴: catch 한 후 적절한 `ErrorCode` 로 다시 throw. +> **남의 리소스는 403 인가 404 인가 (2026-09-23 결정, [decisions/resource-ownership-403-vs-404.md](./decisions/resource-ownership-403-vs-404.md))** — 판정 문장: «요청자가 그 리소스의 존재를 정당하게 알 수 있는가». +> - **개인 소유**(세션·목표·알림·초대 등 한 사람만의 것) → **404** `*_NOT_FOUND`. `findById` 후 소유자 비교(fetch-then-check)를 쓰지 않는다 — 비교 줄을 빼먹으면 곧 IDOR 이고, 403 은 id 존재를 흘린다. +> - **공유·멤버십**(그룹) → 없으면 404, 멤버 아니면 **403** 유지. +> - 404 로 합칠 때 «없음/남의 것» 을 서버 로그에도 **구분해 남기지 않는다**(결정). + --- ## 4. gRPC 에러 매핑 (Spring ↔ AI) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9f4c3b19..f4fe3576 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -102,6 +102,7 @@ - [ai-backend-coupling.md](./ai-backend-coupling.md) — AI↔Backend 결합 방식(분기 A~I) (확정 — H2 프론트 직결 등 다수 분기 채택 확정) - [ai-session-ownership-verification.md](./ai-session-ownership-verification.md) — 세션 소유권 검증 — 신원은 채널①에서만(#187) (진행중 — 정적 사실은 확정, 동적 재현은 미착수) - [api-improvement-opportunities.md](./api-improvement-opportunities.md) — 지금까지 만든 API 표면 감사 (확정 — 9개 항목 전부 커밋 반영 완료) +- [resource-ownership-403-vs-404.md](./resource-ownership-403-vs-404.md) — 남의 리소스에 403 인가 404 인가 — 세션 안에서도 갈림 (확정 — 2026-09-23 C 채택: 개인 소유 404·그룹 403, 로그 구분 안 함) - [architecture-review-2026-08-11.md](./architecture-review-2026-08-11.md) — 아키텍처 회고 — 결함·재조립·점수 (확정 — 새 결함 발견 즉시 수정, 결함 ⑤는 철회) - [circuit-breaker-worker-aggregation.md](./circuit-breaker-worker-aggregation.md) — 서킷브레이커 워커 3개 실패율 합산(#556) (진행중 — 워킹트리에 반영, 커밋은 별도 요청 시) - [grpc-integration-checklist.md](./grpc-integration-checklist.md) — gRPC 좌표 송수신(AI↔Backend) 설계 체크리스트 (진행중 — 일부 완료, 관련 이슈 열려있음) diff --git a/docs/decisions/api-improvement-opportunities.md b/docs/decisions/api-improvement-opportunities.md index 342da0b1..cbda7523 100644 --- a/docs/decisions/api-improvement-opportunities.md +++ b/docs/decisions/api-improvement-opportunities.md @@ -36,6 +36,8 @@ ai-server(Python)는 범위 밖 ([[feedback_minimize_python_changes]]). ## 2. 발견 — 인가(Authorization) — 최우선 +> 🔄 **2026-09-23 정정** — 아래에서 «올바른 패턴»으로 소개한 조회 후 비교 + `ACCESS_DENIED`(403)는 같은 날 `report-read-path.md` ⑫ 가 갭으로 본 방식과 반대였다. 개인 소유 리소스는 소유권을 WHERE 에 넣어 404 로 답하기로 결정했다([resource-ownership-403-vs-404.md](./resource-ownership-403-vs-404.md) 후보 C). `endSession`·세션 피드백도 그렇게 바뀌었다. 아래 원문은 당시 판단 기록으로 둔다. + 이 프로젝트엔 이미 **소유권 체크를 올바르게 하는 패턴**이 존재한다 — `SessionController.endSession`([`SessionController.java:26-32`](../../backend/src/main/java/com/shadowfit/controller/SessionController.java))가 `SessionService.endSession`([`SessionService.java:132-138`](../../backend/src/main/java/com/shadowfit/service/exercise/SessionService.java))에서 `session.getMember().getId().equals(currentMemberId)`로 검증 후 `ACCESS_DENIED`를 던지고, `SessionFeedbackController`도 동일 패턴으로 `memberId`를 서비스에 넘긴다. **아래 항목들은 이 패턴이 이미 사내에 있는데도 빠뜨린 케이스**라는 게 핵심 — "몰라서"가 아니라 "일관되게 안 지켜서"인 게 인터뷰 서사상으로도 더 정직하다. ### 2-① `ExerciseReportController.getSessionReport` — 타인 세션 리포트 열람 가능 — ✅ 해결(`52049d0`) diff --git a/docs/decisions/resource-ownership-403-vs-404.md b/docs/decisions/resource-ownership-403-vs-404.md new file mode 100644 index 00000000..fe3bc390 --- /dev/null +++ b/docs/decisions/resource-ownership-403-vs-404.md @@ -0,0 +1,142 @@ +# 남의 리소스에 대한 응답 — 403 인가 404 인가 + +작성 2026-09-23. 상태: **결정됨(2026-09-23, 사용자 confirm) — 후보 C 채택, 로그 구분 안 함.** §7. +출처: 백엔드 API 감사·클린코드 감사(2026-09-23)가 각자 따로 짚은 항목. 기준 코드 `origin/main` 731f2274. + +--- + +## 1. 질문 + +로그인한 사용자가 **자기 것이 아닌** 리소스 id 로 요청했을 때 무엇을 돌려주는가. + +- **403** `ACCESS_DENIED` — «있긴 한데 네 것이 아니다» +- **404** `*_NOT_FOUND` — «(너에게는) 없다». 없는 id 와 응답이 같다 + +지금은 **같은 리소스(세션) 안에서도 엔드포인트마다 다르고**, 문서 두 개가 서로 반대쪽을 «올바른 패턴»이라 적고 있다. + +| 문서 | 적힌 것 | +|---|---| +| `api-improvement-opportunities.md` §2 (07-15) | `endSession` 의 «조회 → 소유자 비교 → `ACCESS_DENIED`» 를 **올바른 패턴**으로 소개 | +| `report-read-path.md` ⑫ (07-15, 같은 날) | 조회 후 검증(fetch-then-check)을 **갭**으로 보고, 소유권을 WHERE 에 넣어 «존재X/남의 것 둘 다 `SESSION_NOT_FOUND` — 존재 여부 비공개» 로 **해결** | + +같은 날 두 문서가 반대 결론을 냈고, 코드에는 두 패턴이 모두 남았다. + +--- + +## 2. 현황 (전수 — `ACCESS_DENIED`·`NOT_GROUP_*`·`*_NOT_FOUND` 를 던지는 곳을 grep) + +### 2-1. 개인 소유 리소스 — 한 명만의 것 + +| 엔드포인트 | 위치 | 없는 id | 남의 것 | 방식 | +|---|---|---|---|---| +| 세션 리포트 `GET /reports/session/{id}` | `ReportService.java:47` | 404 W003 | **404** W003 | WHERE 소유권 | +| 세션 재부착 | `SessionService.java:215` | 404 | **404** | WHERE 소유권 | +| 세션 삭제 | `SessionService.java:339` | 404 | **404** | WHERE 소유권 | +| 목표 수정·삭제 | `GoalService.java:75,85` | 404 GL001 | **404** | WHERE 소유권 | +| 알림 읽음 | `NotificationService.java:110` | 404 | **404** | WHERE 소유권 | +| **세션 종료** `PATCH /sessions/{id}/end` | `SessionService.java:302-306` | 404 W003 | **403** A002 | 조회 후 비교 | +| **세션 피드백** `GET /sessions/{id}/feedbacks`·`/feedback-summary` | `SessionFeedbackQueryService.java:50-53` | 404 W003 | **403** A002 | 조회 후 비교 | +| **초대 수락·거절** `POST /invitations/{id}/accept·decline` | `GroupInvitationService.java:87-91` | 404 G004 | **403** A002 | 조회 후 비교 | + +→ 개인 소유 리소스는 **8곳 중 5곳이 404, 3곳이 403**이다. 403인 3곳에서만 존재 여부가 드러난다. + +### 2-2. 공유 리소스 — 그룹(멤버십으로 접근) + +| 엔드포인트 | 없는 그룹 | 멤버 아님 | +|---|---|---| +| 그룹 상세·피드·캘린더·초대 발송 등 (`GroupService`·`GroupEventService`·`GroupAttendanceCalendarService`·`GroupInvitationService:35`·`MemberAttendanceStatusService`) | 404 G001 | 403 G002 | +| 그룹장 전용(코드 재발급·양도) | 404 G001 | 403 G002 / 그룹장 아님 403 G007 | + +그룹은 **일관되게** «없음 404 / 멤버 아님 403» 이다. 여기서도 존재 여부는 드러난다. + +### 2-3. 이 질문과 관계없는 403 (범위 밖) + +- `MemberController.requireSelf`: 경로의 이메일이 본인이 아니면 403이다. **조회 전에** 비교하므로 없는 이메일이든 남의 이메일이든 똑같이 403이 나간다. 존재 여부가 새지 않는다. 경로 자체의 문제는 #810과 API 감사 5번에서 다룬다. +- `TrainerAuthorizationService`: 담당이 아니면 403 T001. 역할·배정 관계의 판정이지 리소스 소유권이 아니다. +- 재촉·응원: 같은 모임이 아니면 403. 관계 판정이다. + +--- + +## 3. 무엇이 새는가 (위협 모델) + +- id 는 전부 `BIGINT AUTO_INCREMENT` 라 **순차 대입이 쉽다**(`V1__baseline.sql`, `V12__add_group_tables.sql`). +- 403과 404가 갈리면, 로그인한 아무 사용자나 `PATCH /sessions/1..N/end`를 돌려 **존재하는 세션 id 전체**를 알아낼 수 있다. 여기서 드러나는 것: + - **플랫폼 전체의 세션 수와 증가 속도.** 사업 지표다. 경쟁사가 가입 한 번으로 알 수 있다. + - 세션 **상태**(진행 중인지·끝났는지)는 새지 않는다. `endSession`의 종료 멱등 분기가 소유자 확인 **뒤**에 있어서, 남의 세션은 상태와 무관하게 늘 403이다. 새는 것은 «그 id가 존재한다»뿐이다. +- 리포트·피드백 **내용**은 새지 않는다. 403이든 404든 본문은 주지 않는다. 즉 이건 IDOR(내용 유출)가 아니라 **존재 오라클**이다. 심각도는 IDOR보다 한 단계 낮다. +- 🔴 **더 큰 비용은 구조다.** «조회 → 비교» 방식은 **비교 줄 하나를 빼먹으면 곧바로 IDOR**가 된다. 이 프로젝트는 실제로 그렇게 뚫린 적이 있다(`api-improvement-opportunities.md` §2-①, 리포트 IDOR, `52049d0`). WHERE에 소유권을 넣는 방식은 빼먹을 줄이 없다. 조회 자체가 남의 행을 돌려주지 않는다. + +--- + +## 4. 후보 + +### A. 404로 통일 (소유권을 WHERE에) + +남의 것은 전부 없는 것처럼 답한다. 그룹 비멤버도 404로 바꾼다. + +- ➕ 존재 오라클이 전부 닫힌다. GitHub의 비공개 저장소가 이렇게 한다. RFC 9110 §15.5.4(403)도 «금지된 대상의 존재를 숨기려는 서버는 404로 대신 응답할 수 있다(MAY)»고 적는다. +- ➕ 개인 소유 3곳은 이미 있는 `findByIdAndMemberId` 계열을 재사용하면 된다. +- ➖ **그룹에는 맞지 않는다.** 초대 코드로 들어와 id를 이미 아는 사람, 방금 탈퇴·강퇴된 사람에게 «그 모임은 없다»고 답하면 **거짓말**이 된다. 프론트는 «더 이상 멤버가 아니다»를 보여 줘야 한다. +- ➖ 디버깅할 때 «정말 없나, 권한이 없나»를 응답만으로는 못 가른다. 서버 로그에서 가를 수는 있다(§6). + +### B. 403으로 통일 (존재 확인 후 권한) + +리포트·재부착·삭제·목표·알림까지 403으로 바꾼다. + +- ➕ 의미가 정직하다. «있지만 네 것이 아님»을 그대로 말한다. +- ➖ 존재 오라클이 **8곳 전부로 넓어진다.** +- ➖ 07-15에 의도적으로 404로 바꾼 5곳을 되돌리는 셈이다(`report-read-path.md` ⑫). 그리고 IDOR에 취약한 «조회 → 비교» 구조를 전부로 퍼뜨린다(§3 🔴). + +### C. 리소스 성격별 규칙 — 개인 소유는 404, 공유는 403 유지 ← **추천** + +규칙을 한 줄로 쓰면 이렇다. **«요청자가 그 리소스의 존재를 정당하게 알 수 있는가»** + +- **개인 소유**(세션·목표·알림·초대): 남은 존재를 알 이유가 없다 → **404**. WHERE 소유권으로 통일한다. +- **공유·멤버십**(그룹): 존재는 초대 코드·탈퇴 이력으로 정당하게 알 수 있고, «멤버가 아님»은 사용자에게 필요한 정보다 → **403 유지**. +- 초대(invitation)가 개인 쪽인 이유: 초대 한 건은 초대받은 **한 사람**의 것이다. 남이 그 id를 알 정당한 경로가 없다. + +**트레이드오프:** +- ➕ 개인 소유 리소스의 존재 오라클이 닫히고, 이미 5곳이 따르는 규칙에 나머지 3곳을 맞추는 것이라 면적이 작다. +- ➕ «조회 → 비교» 방식이 개인 소유 리소스에서 사라진다. IDOR가 구조적으로 재발하지 않는다. +- ➖ 그룹 id의 존재 오라클은 남는다. 그룹 수와 증가 속도가 드러난다. 이걸 닫으려면 그룹 비멤버만 404로 바꾸는 C′가 있지만, 탈퇴한 사람에게 거짓말이 되는 A의 단점이 그대로 돌아온다. +- ➖ 규칙이 둘이라 «새 리소스는 어느 쪽인가»를 매번 판단해야 한다. 판정 문장을 `17-error-codes.md`나 이 문서에 못박아 두면 된다. + +--- + +## 5. 변경 면적 (C 기준, 실측) + +| 파일 | 변경 | +|---|---| +| `SessionService.endSession` (:302-306) | `findById` + 비교 → `findByIdAndMemberId` + `SESSION_NOT_FOUND` | +| `SessionFeedbackQueryService.ensureOwnership` (:50-53) | 같음. 존재 여부만 확인하는 조회라면 `existsByIdAndMemberId`로 충분한지 확인이 필요하다(미확인) | +| `GroupInvitationService.getRespondableInvitation` (:87-91) | `findByIdAndInviteeId`(신설) + `INVITATION_NOT_FOUND` | +| 테스트 | main 3곳에 맞춰: `SessionServiceTest:303-310`, `SessionFeedbackQueryServiceTest:86-93·116-123`, `GroupInvitationServiceTest:138-146·202-210`, `GroupInvitationControllerIntegrationTest:140-151`(403 → 404). **5개 파일, 단언 6곳** | +| 프론트 | `endSession`·`feedback-summary`는 403·404를 status로 가르지 않는다(`report/[id].tsx:67`은 status를 로그만 찍음). 초대 수락에서 403을 따로 처리하는 곳은 없다(grep). **변경 없음으로 판단**했다. 단 `feedback-summary`의 화면 문구는 확인하지 않았다 | +| 문서 | 이 문서 결정 박제. `api-improvement-opportunities.md` §2의 «올바른 패턴» 서술에 정정 주석을 단다 | + +A는 여기에 그룹 쪽 약 10곳(§2-2)과 프론트 `group/[id].tsx:103`(지금 403·404를 같이 처리하므로 영향은 작을 것으로 추정)이 더해진다. B는 개인 소유 5곳을 되돌리는 방향이라 면적은 비슷하지만 방향이 반대다. + +--- + +## 6. 어느 후보든 같이 할 것 + +- **관측:** 404로 뭉개면 운영자도 둘을 못 가른다. `SESSION_NOT_FOUND`를 던지는 자리의 WARN 로그에 «없음/남의 것»을 **서버 로그에만** 남길지 정해야 한다. WHERE 방식은 조회가 하나라 원래는 구분이 안 된다. 구분하려면 실패했을 때만 `existsById`를 한 번 더 부르는데, 실패 경로에만 붙는 비용이다. 이것도 결정 대상이다. +- **규칙 문장 못박기:** `17-error-codes.md` §6 근처에 판정 문장을 적는다. 다음 리소스를 만들 때 고민하지 않게 하기 위해서다. + +--- + +## 7. 결정 + +| 항목 | 결정 | +|---|---| +| 후보 | ✅ **C** — 개인 소유 404, 그룹 403 유지 (2026-09-23 사용자 confirm) | +| 404일 때 서버 로그에 «남의 것» 구분을 남기는가 | ✅ **남기지 않는다** (2026-09-23 사용자 confirm) — 실패 경로의 추가 조회도 없다 | +| 그룹 존재 오라클을 수용하는가 (C′ 여부) | C 채택에 따라 **수용**(그룹은 403 유지). C′ 는 채택하지 않음 | + +### 적용 (같은 PR) + +- `SessionService.endSession` → `findByIdAndMemberId` +- `SessionFeedbackQueryService.ensureOwnership` → `existsByIdAndMemberId`(신설 — 세션 행을 쓰지 않으므로 엔티티를 싣지 않는다. §5 의 미확인 항목은 이걸로 닫음) +- `GroupInvitationService.getRespondableInvitation` → `findByIdAndInviteeId`(신설) + `INVITATION_NOT_FOUND` +- 테스트: 단위 3파일·통합 1파일의 403 단언 → 404. 신규 `SessionOwnershipNotFoundTest` — 실제 DB 로 «남의 세션 응답 본문 == 없는 세션 응답 본문»(timestamp 제외)을 세 엔드포인트에 대해 고정하고, 남의 종료 요청이 `endTime` 을 안 찍는 것까지 본다 +- 판정 문장은 `17-error-codes.md` §3 에 옮겨 적었다