Skip to content
Open
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 @@ -25,9 +25,11 @@ public interface SessionRepository extends JpaRepository<Session,Long> {
Optional<Session> findSessionWithExerciseByIdAndMemberId(@Param("sessionId") Long sessionId,
@Param("memberId") Long memberId);

// 개별 세션 삭제(deleteSession) 전용 — exercise fetch join 불필요, 소유권만 WHERE절로 확인.
// 세션 삭제·종료(deleteSession·endSession) — exercise fetch join 불필요, 소유권만 WHERE절로 확인.
Optional<Session> findByIdAndMemberId(Long sessionId, Long memberId);

boolean existsByIdAndMemberId(Long sessionId, Long memberId);

// 서킷브레이커 OPEN 자동 재부착(REATTACH_ANALYSIS) 전용 — 위 findSessionWithExerciseByIdAndMemberId
// 와 달리 소유권(memberId) 조건이 없다. 호출 경로가 사용자 요청이 아니라 시스템(서킷브레이커
// 이벤트 → 아웃박스 발행기)이라 "요청자"가 없다 — 애초에 대조할 currentMemberId 가 없다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public interface GroupInvitationRepository extends JpaRepository<GroupInvitation
// 초대 생성 시 중복 초대 방지 — 이미 PENDING 인 초대가 있으면 재초대를 막는다.
boolean existsByGroupIdAndInviteeIdAndStatus(Long groupId, Long inviteeId, InvitationStatus status);

// 수락·거절 — 초대받은 본인 것만 찾는다. 남의 초대는 없는 것과 같다(404, 존재 여부 비공개).
Optional<GroupInvitation> findByIdAndInviteeId(Long invitationId, Long inviteeId);

// GET /invitations/mine.
List<GroupInvitation> findAllByInviteeIdAndStatus(Long inviteeId, InvitationStatus status);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,18 +294,15 @@ public void completeSession(SessionCompleteRequest request) {
* - endTime 만 즉시 기록. 통계 갱신(totalReps/avgSync) 은 AI 의 CompleteAnalysis 콜백이 별도 처리
* - AI 로의 gRPC 는 이 경로에서 <b>일어나지 않는다</b>. 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 남의 세션과 없는 세션이 <b>같은 답</b>을 받는지 검증한다
* (decisions/resource-ownership-403-vs-404.md 후보 C — 개인 소유 리소스는 404).
*
* <p>예전엔 이 세 엔드포인트만 조회 후 비교라 남의 세션에 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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)
Expand All @@ -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);
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading