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 @@ -2,6 +2,7 @@

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
Expand All @@ -21,13 +22,20 @@ public class GlobalExceptionHandler {
public ResponseEntity<ErrorResponseDto> handleBusinessException(BusinessException e) {
ErrorCode code = e.getErrorCode();
log.warn("BusinessException: {} ({})", code.getCode(), code.getMessage());
return ResponseEntity
.status(code.getStatus())
.body(ErrorResponseDto.builder()
.status(code.getStatus())
.message(code.getMessage())
.timestamp(LocalDateTime.now())
.build());
return buildResponse(code);
}

/**
* ⚠️ 2026-07-24 추가: @PreAuthorize("hasRole(...)")가 던지는 AccessDeniedException은 MVC
* 핸들러 호출(디스패처 서블릿) 도중 발생해서, SecurityConfig의 CustomAccessDeniedHandler
* (필터체인 레벨 전용)까지 못 가고 여기 도착함. 이 핸들러가 없으면 아래
* handleUnexpectedException(Exception.class)이 그냥 삼켜서 403 대신 500이 나가던 버그가
* 있었음 — AdminAuthorizationIntegrationTest로 발견.
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponseDto> handleAccessDeniedException(AccessDeniedException e) {
log.warn("AccessDeniedException: {}", e.getMessage());
return buildResponse(ErrorCode.ACCESS_DENIED);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
Expand All @@ -37,24 +45,27 @@ public ResponseEntity<ErrorResponseDto> handleValidationException(MethodArgument
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("Validation failed: {}", message);
return ResponseEntity
.status(code.getStatus())
.body(ErrorResponseDto.builder()
.status(code.getStatus())
.message(message.isEmpty() ? code.getMessage() : message)
.timestamp(LocalDateTime.now())
.build());
return buildResponse(code, message.isEmpty() ? code.getMessage() : message);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponseDto> handleUnexpectedException(Exception e) {
ErrorCode code = ErrorCode.INTERNAL_SERVER_ERROR;
log.error("Unhandled exception", e);
return buildResponse(ErrorCode.INTERNAL_SERVER_ERROR);
}

// CodeRabbit 지적 반영(2026-07-24): 4개 핸들러가 거의 동일한 ErrorResponseDto 생성 로직을
// 반복하고 있어 공통 헬퍼로 추출.
private ResponseEntity<ErrorResponseDto> buildResponse(ErrorCode code) {
return buildResponse(code, code.getMessage());
}

private ResponseEntity<ErrorResponseDto> buildResponse(ErrorCode code, String message) {
return ResponseEntity
.status(code.getStatus())
.body(ErrorResponseDto.builder()
.status(code.getStatus())
.message(code.getMessage())
.message(message)
.timestamp(LocalDateTime.now())
.build());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -63,6 +64,10 @@ public String getUserEmail(String token){
}

// JWT 검증
// ⚠️ 2026-07-24 수정: 위에서 io.jsonwebtoken.security.SecurityException을 명시 import하지
// 않았을 땐 여기 SecurityException이 java.lang.SecurityException으로 잘못 resolve돼(io.jsonwebtoken
// 패키지엔 이 이름의 클래스가 base package에 없음), 서명 변조 토큰(io.jsonwebtoken.security.
// SignatureException)이 이 catch에 안 걸리고 그대로 던져지는 버그가 있었음 — JwtUtilTest로 발견.
public boolean isValidToken(String token){
try{
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import jakarta.persistence.Version;
import lombok.AccessLevel;

Expand All @@ -26,6 +28,7 @@ public class Session {
// 연관관계 설정 (N:1)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE) // 실 schema.sql의 ON DELETE CASCADE와 일치 — 회원 탈퇴 시 함께 정리
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.shadowfit.model.member.Member;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import java.math.BigDecimal;
import java.time.LocalDate;
Expand All @@ -25,6 +27,7 @@ public class DailyLog extends BaseTimeEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE) // 실 schema.sql의 ON DELETE CASCADE와 일치 — 회원 탈퇴 시 함께 정리
private Member member;

@Column(nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDateTime;
import java.util.List;
Expand Down Expand Up @@ -144,9 +146,26 @@ public Long startAnalysis(VideoRequestDto appDto, Long currentMemberId) {

Session savedSession = sessionService.createSession(appDto, currentMemberId, finalUrl);
Long sessionId = savedSession.getId();

// 비동기로 FastAPI에 분석 요청
this.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, member.getSelectedPersona().name());
String persona = member.getSelectedPersona().name();

// 비동기로 FastAPI에 분석 요청 — self를 거쳐야 @Async가 Spring 프록시를 타고 실제로
// 비동기 실행됨. this.로 호출하면 자기호출(self-invocation)이라 AOP 프록시를 우회해서
// @Async가 조용히 무시되고 동기 실행되는 문제가 있었음(2026-07-24, 테스트로 발견) —
// completeSession→applyCompleteFromApp에 이미 쓰던 self 패턴을 여기에도 동일 적용.
//
// ⚠️ CodeRabbit 지적으로 추가 수정(2026-07-24): self.로 진짜 비동기가 되면서 세션 INSERT가
// 커밋되기 전에 이 비동기 작업이 먼저 실행될 수 있는 레이스가 새로 생김 — 서킷 OPEN/gRPC
// 에러 시 sendAnalysisRequestToFastApi가 markAsFailedIfStillInProgress로 세션을 찾는데,
// 아직 커밋 전이라 못 찾으면 조용히 no-op(스케줄러 30분+ 타임아웃까지 방치). endSession→
// stopAnalysis와 동일하게 afterCommit 이후로 미뤄서 방지.
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
self.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, persona);
}
}
);

return sessionId;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.shadowfit.global.security.jwt;

import com.shadowfit.global.security.auth.CustomUserDetails;
import com.shadowfit.model.member.Member;
import com.shadowfit.model.member.UserRole;
import com.shadowfit.service.Member.CustomUserDetailsService;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
* JwtAuthFilter 단위테스트 — 인증 필터 자체가 지금까지 무테스트였던 영역. 정상 인증뿐 아니라
* 무효 토큰·블랙리스트·회원 없음·헤더 없음·/ws 경로 예외 케이스까지 검증.
*/
@DisplayName("JwtAuthFilter 테스트")
class JwtAuthFilterTest {

@Mock private CustomUserDetailsService customUserDetailsService;
@Mock private JwtUtil jwtUtil;
private JwtBlacklist jwtBlacklist; // 순수 인메모리 맵이라 실사용
private JwtAuthFilter filter;

@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
jwtBlacklist = new JwtBlacklist();
filter = new JwtAuthFilter(customUserDetailsService, jwtUtil, jwtBlacklist);
}

@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}

private CustomUserDetails userDetails() {
Member member = Member.builder().id(1L).email("test@test.com")
.username("u").password("pw").role(UserRole.USER).build();
return new CustomUserDetails(member);
}

@Test
@DisplayName("유효한 Bearer 토큰이면 SecurityContext에 인증 정보가 설정됨")
void validToken_setsAuthentication() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1");
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

when(jwtUtil.isValidToken("valid-token")).thenReturn(true);
when(jwtUtil.getUserEmail("valid-token")).thenReturn("test@test.com");
when(customUserDetailsService.loadUserByUsername("test@test.com")).thenReturn(userDetails());

filter.doFilter(request, response, chain);

assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("test@test.com");
verify(chain, times(1)).doFilter(request, response);
}

@Test
@DisplayName("Authorization 헤더가 없으면 인증 없이 체인만 진행")
void noAuthHeader_skipsAuthentication() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

filter.doFilter(request, response, chain);

assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
verify(chain, times(1)).doFilter(request, response);
verify(jwtUtil, never()).isValidToken(org.mockito.ArgumentMatchers.anyString());
}

@Test
@DisplayName("무효 토큰이면 인증 안 됨, 체인은 그대로 진행(에러 응답 아님)")
void invalidToken_noAuthentication() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1");
request.addHeader("Authorization", "Bearer bad-token");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

when(jwtUtil.isValidToken("bad-token")).thenReturn(false);

filter.doFilter(request, response, chain);

assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
verify(chain, times(1)).doFilter(request, response);
}

@Test
@DisplayName("블랙리스트에 등록된 토큰이면(로그아웃됨) 검증에 유효해도 인증 안 됨")
void blacklistedToken_noAuthentication() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1");
request.addHeader("Authorization", "Bearer logged-out-token");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

when(jwtUtil.isValidToken("logged-out-token")).thenReturn(true);
jwtBlacklist.add("logged-out-token", System.currentTimeMillis() + 100000);

filter.doFilter(request, response, chain);

assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
verify(chain, times(1)).doFilter(request, response);
}

@Test
@DisplayName("토큰은 유효하지만 회원을 못 찾으면 예외를 삼키고 체인은 계속 진행")
void validTokenButUserNotFound_swallowsExceptionAndContinues() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sessions/1");
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

when(jwtUtil.isValidToken("valid-token")).thenReturn(true);
when(jwtUtil.getUserEmail("valid-token")).thenReturn("ghost@test.com");
when(customUserDetailsService.loadUserByUsername("ghost@test.com"))
.thenThrow(new UsernameNotFoundException("no such user"));

filter.doFilter(request, response, chain);

assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
verify(chain, times(1)).doFilter(request, response); // 예외가 필터 체인을 끊지 않음
}

@Test
@DisplayName("/ws 로 시작하는 경로는 인증 로직 자체를 건너뜀")
void wsPath_skipsAuthEntirely() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/ws/somewhere");
request.addHeader("Authorization", "Bearer whatever");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);

filter.doFilter(request, response, chain);

verify(jwtUtil, never()).isValidToken(org.mockito.ArgumentMatchers.anyString());
verify(chain, times(1)).doFilter(request, response);
}
}
Loading