Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
320d799
feat: Redis 설정
jaelyangChoi Aug 3, 2025
3dd250b
feat: 소셜로그인, 재발급 시 refresh token을 redis에 저장/삭제/조회
jaelyangChoi Aug 3, 2025
a261c6b
feat: 로그아웃, 회원 탈퇴 시 Redis에서 Refresh Token 삭제
jaelyangChoi Aug 3, 2025
b3df814
refactor: jwtFilter 중복 코드 제거
jaelyangChoi Aug 4, 2025
709e1f6
feat: 블랙리스트 토큰 여부 적용
jaelyangChoi Aug 4, 2025
150b439
feat: 블랙리스트 토큰 저장 구현
jaelyangChoi Aug 4, 2025
0eaaa4a
feat: 탈퇴 시 블랙리스트 토큰 저장 적용
jaelyangChoi Aug 4, 2025
8e92556
test: 탈퇴 시 블랙리스트 토큰 저장 적용
jaelyangChoi Aug 4, 2025
3c527fe
Merge branch 'develop' into feature/#97-redis
jaelyangChoi Aug 10, 2025
e5b0ce1
fix: 누락된 문서 추가
jaelyangChoi Aug 10, 2025
c9e0921
Merge branch 'develop' into feature/#97-redis
jaelyangChoi Aug 10, 2025
fa96985
refactor: 로그아웃, 탈퇴 시 사용 API 통일
jaelyangChoi Aug 14, 2025
8d8026d
새로ìšci: 개발계용 dockerfile 생성
jaelyangChoi Aug 17, 2025
0820ecd
Merge branch 'develop' into feature/#97-redis
jaelyangChoi Aug 17, 2025
3c50ed7
ci: 개발계 배포 스크립트
jaelyangChoi Aug 19, 2025
c602e4f
test: Redis 미기동으로 테스트 실패 방지
jaelyangChoi Aug 19, 2025
b04081c
ci: 배포 스크립트 수정
jaelyangChoi Aug 19, 2025
166e9c0
feat: RedisConfig 삭제 (Spring Boot 자동설정 사용)
jaelyangChoi Aug 19, 2025
7c046c1
test: redis mocking
jaelyangChoi Aug 21, 2025
9442721
chore: 에러코드 수정
jaelyangChoi Aug 21, 2025
f0252be
chore: 불필요 요소 삭제
jaelyangChoi Aug 21, 2025
11ba9c2
Merge branch 'develop' into feature/#97-redis
jaelyangChoi Aug 21, 2025
6023d99
fix: JWT 로그아웃 필터 유형 변경
jaelyangChoi Aug 22, 2025
4ceb289
test: JWT 로그아웃 필터 관련 테스트 코드 수정
jaelyangChoi Aug 22, 2025
3055b56
fix: 트랜잭션 전파 수정
jaelyangChoi Aug 22, 2025
32dfe9d
feat: 에러코드 추가
jaelyangChoi Aug 22, 2025
76a153a
chore: 불필요 요소 삭제
jaelyangChoi Aug 22, 2025
e268b25
Merge remote-tracking branch 'origin/develop' into feature/#97-redis
jaelyangChoi Aug 23, 2025
7e691cf
chore: 주석 수정
jaelyangChoi Aug 23, 2025
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
26 changes: 26 additions & 0 deletions capturecat-core/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
services:
dev-capturecat-db:
image: postgres
environment:
POSTGRES_DB: capturecat
POSTGRES_USER: capturecat
POSTGRES_PASSWORD: capturecat77
MYSQL_ROOT_PASSWORD:
MYSQL_DATABASE:
volumes:
- /home/ubuntu/capturecat/database/docker-postgresql/postgresql_data:/var/lib/postgresql/data
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1 -p 5432"]
interval: 5s
retries: 10
dev-capturecat-cache-server:
image: redis
command: ["redis-server", "--requirepass", "capturecat77"]
ports:
Comment on lines +20 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid hardcoding the Redis password in command; pass via env and expand at runtime.

Keeps the password out of the command line and fixes the healthcheck change above (uses $REDIS_PASSWORD).

Apply this diff:

-    command: ["redis-server", "--requirepass", "capturecat77"]
+    environment:
+      REDIS_PASSWORD: capturecat77
+    command: ["sh", "-c", "redis-server --requirepass \"$REDIS_PASSWORD\""]

Follow-up (recommended): move the secret to an .env file and reference it, so it isn’t committed:

  • .env (gitignored): REDIS_PASSWORD=your-local-only-secret
  • compose:
    • environment: { REDIS_PASSWORD: ${REDIS_PASSWORD} }
    • command: ["sh","-c","redis-server --requirepass \"$REDIS_PASSWORD\""]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
command: ["redis-server", "--requirepass", "capturecat77"]
ports:
environment:
REDIS_PASSWORD: capturecat77
command: ["sh", "-c", "redis-server --requirepass \"$REDIS_PASSWORD\""]
ports:
🤖 Prompt for AI Agents
In capturecat-core/compose.yml around lines 20-21 the Redis password is
hardcoded in the command, which exposes secrets and breaks the healthcheck
expecting $REDIS_PASSWORD; change the service to accept REDIS_PASSWORD from
environment and run the server via a shell expansion. Add an environment entry
supplying REDIS_PASSWORD (sourced from compose variable or .env), and replace
the command with a shell-invoked form that expands the env var at runtime (e.g.,
sh -c 'redis-server --requirepass "$REDIS_PASSWORD"'). Optionally move the
secret into a gitignored .env file and reference it via compose variable
substitution.

- 6379:6379
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 10
Comment on lines +23 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Redis healthcheck will always fail due to missing auth; authenticate in the check.

You’ve enabled requirepass on Redis, but the healthcheck runs redis-cli ping without -a <password>. That returns NOAUTH and marks the container unhealthy.

Apply this diff to authenticate in the healthcheck (and avoid noisy auth warnings):

-    healthcheck:
-      test: [ "CMD", "redis-cli", "ping" ]
-      interval: 5s
-      retries: 10
+    healthcheck:
+      test: [ "CMD-SHELL", "redis-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping" ]
+      interval: 5s
+      retries: 10
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 10
healthcheck:
test: [ "CMD-SHELL", "redis-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping" ]
interval: 5s
retries: 10
🤖 Prompt for AI Agents
In capturecat-core/compose.yml around lines 23-26 the healthcheck runs redis-cli
ping without authentication so it will return NOAUTH; change the healthcheck to
run a shell command that authenticates using an environment variable (e.g.
REDIS_PASSWORD) such as using CMD-SHELL to run redis-cli --no-auth-warning -a
"$REDIS_PASSWORD" ping and verify the output is PONG (exit 0) so the container
is marked healthy, and ensure REDIS_PASSWORD is defined in the service env or
secrets.

Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.addFilterBefore(new JwtFilter(jwtUtil, tokenService), JwtLoginFilter.class)
.addFilterBefore(new JwtFilter(jwtUtil, tokenService), UsernamePasswordAuthenticationFilter.class)
.addFilterAt(
new JwtLoginFilter(authenticationManager(authenticationConfiguration), tokenService),
UsernamePasswordAuthenticationFilter.class)
.addFilterAt(new JwtLogoutFilter(tokenService), LogoutFilter.class)
.addFilterBefore(new JwtLogoutFilter(tokenService), LogoutFilter.class)
.authorizeHttpRequests(
authorizeRequests -> authorizeRequests
.requestMatchers("/health", "/docs/**", "/token/reissue", "/v1/auth/**", "/v1/user/join")
.requestMatchers("/health", "/docs/**", "/token/reissue", "/v1/auth/**", "/v1/user/join", "/logout")
.permitAll()
.anyRequest()
.hasRole("USER"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,9 @@ private void rejectInvalidToken(HttpServletResponse response, ErrorType errorTyp
objectMapper.writeValue(response.getWriter(), ApiResponse.error(errorType));
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return "/logout".equals(path); // 로그아웃은 스킵
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import com.capturecat.core.api.user.dto.UserReqDto.LoginReqDto;
import com.capturecat.core.domain.user.UserRole;
Expand All @@ -32,6 +33,7 @@
* 소셜 로그인/회원가입이 아닌,
* 일반 회원가입 후 /login 경로로, id, password로 로그인한 경우 (개발 용도)
*/
@Slf4j
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Logging enabled: avoid token leakage elsewhere.

  • Adding @slf4j here is fine, and your success log doesn’t include tokens, which is good.
  • Cross-check other classes (e.g., TokenService) to ensure access/refresh tokens aren’t logged at any level. Sensitive token values should never hit logs.

Scan for any logging that outputs tokens:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find log lines that might include "token" in their message
rg -nP --type=java -C1 'log\.(trace|debug|info|warn|error)\s*\(.*token' capturecat-core || true

# A few more token-specific strings commonly logged
rg -nP --type=java -C1 'Refresh Token:|Blacklist Token:' capturecat-core || true

Length of output: 1681


Critical: Remove all logging of sensitive token values

The scan confirms that several log statements are exposing raw token and secret values. These must be removed or masked immediately to prevent security risks:

• capturecat-core/src/main/java/com/capturecat/core/service/auth/SocialService.java:162

log.info("Apple /token params: {}", params.get("client_secret")); // exposes client_secret

• capturecat-core/src/main/java/com/capturecat/core/service/auth/TokenService.java:75

log.info("Refresh Token: {}", refreshToken); // exposes raw refresh token

• capturecat-core/src/main/java/com/capturecat/core/service/auth/TokenService.java:148

log.info("Blacklist Token: {}", accessToken); // exposes raw access token

The debug log on line 123 of TokenService (“Deleting refresh token for user {}”) is acceptable, since it only logs the username. All other logs that print token or secret values must be removed or replaced with non-sensitive identifiers (e.g., user IDs, token hashes). Sensitive token values should never appear in any log output.

🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/config/jwt/JwtLoginFilter.java
around line 36 and in the related files
capturecat-core/src/main/java/com/capturecat/core/service/auth/SocialService.java
line 162,
capturecat-core/src/main/java/com/capturecat/core/service/auth/TokenService.java
lines 75 and 148, remove any logging that prints raw tokens or client secrets;
instead log only non-sensitive identifiers (e.g., user ID, username) or a
deterministic hash/truncated fingerprint of the token if you need traceability,
and update messages to avoid including secret values; ensure no println/log
statements output the actual token or client_secret and add a short comment
noting that sensitive values must not be logged.

@RequiredArgsConstructor
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {

Expand Down Expand Up @@ -64,6 +66,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR

//토큰 발급
Map<TokenType, String> tokenMap = tokenIssueService.issue(username, UserRole.fromRoleString(role));
log.info("[JwtLoginFilter.successfulAuthentication] 사용자 로그인({}), 토큰 발급", username);

//Header에 실어 응답
response.setHeader(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + tokenMap.get(TokenType.ACCESS));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,75 @@

import java.io.IOException;

import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import org.springframework.web.filter.OncePerRequestFilter;

import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import com.capturecat.core.service.auth.TokenService;
import com.capturecat.core.support.error.CoreException;
import com.capturecat.core.support.error.ErrorType;
import com.capturecat.core.support.response.ApiResponse;

@Slf4j
@RequiredArgsConstructor
public class JwtLogoutFilter extends GenericFilterBean {
public class JwtLogoutFilter extends OncePerRequestFilter {

private static final String LOGOUT_PATH = "/logout";
private final TokenService tokenService;
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
doFilter((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse, filterChain);
}

/** 로그아웃
* Refresh 토큰을 받아 DB에서 삭제 후 쿠키 null로 초기화하여 응답
* (모든 기기에서 로그아웃 시 username 기반으로 모든 refresh 토큰 삭제)
*/
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException, ServletException {

// 로그아웃 요청일 때
if (!request.getRequestURI().equals(LOGOUT_PATH)) {
filterChain.doFilter(request, response);
return;
protected boolean shouldNotFilter(HttpServletRequest request) {
// 1) ERROR/ASYNC 등 요청 아닌 디스패치 스킵
if (request.getDispatcherType() != DispatcherType.REQUEST) {
return true;
}
// 2) POST만 허용
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 3) /logout 경로만 통과
return !LOGOUT_PATH.equals(request.getRequestURI());
}

// 로그아웃
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException {
try {
String accessHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String refreshHeader = request.getHeader(JwtUtil.REFRESH_TOKEN_HEADER);
if (!StringUtils.hasText(accessHeader) || !StringUtils.hasText(refreshHeader)) {
throw new CoreException(ErrorType.INVALID_AUTH_TOKEN);
throw new CoreException(ErrorType.INVALID_LOGOUT_AUTH_TOKEN);
}
log.info("LogoutFilter hit: dispatcher={}, path={}", request.getDispatcherType(), request.getServletPath());

tokenService.revokeUserTokens(accessHeader, refreshHeader);

response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), ApiResponse.success());
} catch (CoreException e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), ApiResponse.error(e.getErrorType()));
return;
} catch (Exception e) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(),
ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR));
}

// 성공 응답
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), ApiResponse.success());
// 체인 종료 (추가 필터로 넘기지 않음)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;

import com.capturecat.core.support.error.CoreException;
import com.capturecat.core.support.error.ErrorType;

@Component
public class JwtUtil {

Expand Down Expand Up @@ -77,10 +80,11 @@ public boolean isValid(String token) {
return true;
}

public String resolveToken(String header) {
return header != null && header.startsWith(BEARER_PREFIX)
? header.substring(BEARER_PREFIX.length()).trim()
: null;
public String resolveToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith(JwtUtil.BEARER_PREFIX)) {
throw new CoreException(ErrorType.INVALID_ACCESS_TOKEN);
}
return authHeader.substring(BEARER_PREFIX.length()).trim();
}

// JWT 파싱하여 만료일자(ms) 반환
Expand All @@ -89,7 +93,6 @@ public long getExpiration(String token) {
return claims.getExpiration().getTime();
}


// username(subject) 추출
public String getUsername(String token) {
return extractClaims(token).getSubject();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,12 @@ private void saveRefreshToken(String username, String refreshToken) {
* Refresh Token 삭제 및 Access Token 블랙리스트 등록
*/
public void revokeUserTokens(String accessTokenHeader, String refreshTokenHeader) {
blacklistAccessToken(accessTokenHeader);
deleteValidRefreshToken(refreshTokenHeader);
try {
blacklistAccessToken(accessTokenHeader);
deleteValidRefreshToken(refreshTokenHeader);
} catch (Exception e) {
throw new CoreException(ErrorType.INTERNAL_SERVER_ERROR);
}
}

/**
Expand Down Expand Up @@ -145,6 +149,7 @@ public void blacklistAccessToken(String authHeader) {
redisTemplate.opsForValue()
.set(blacklistKey(accessToken), "blacklisted", remainMillis, TimeUnit.MILLISECONDS);
}
log.info("Blacklist Token: {}", accessToken);
}

public boolean isBlacklisted(String accessToken) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ public String withdraw(LoginUser loginUser, String reason) {
return resultMessage;
}

@Transactional
protected void deleteUserAndRelated(Long userId) {
//1. 즐겨찾기 삭제
bookmarkRepository.deleteByUserId(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
public class WithdrawLogService {
private final WithdrawLogRepository withdrawLogRepository;

// 전파 시 실패해도 록백하지 않도록
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public void save(Long userId, String reason) {
try {
WithdrawLog log = WithdrawLog.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public enum ErrorCode {
UNLINK_SOCIAL_FAIL("소셜 로그인 연결 해제에 실패했습니다."),
FETCH_SOCIAL_TOKEN_FAIL("소셜 서비스로부터 idToken 혹은 unlinkKey 획득에 실패했습니다."),
SOCIAL_API_ERROR("소셜 서비스 API 호출 결과 실패를 응답받았습니다."),
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다.");
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다."),
INTERNAL_SERVER_ERROR("서버에서 오류가 발생했습니다."),
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다.");

private final String message;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ public enum ErrorType {
BOOKMARK_DUPLICATION(HttpStatus.BAD_REQUEST, ErrorCode.ALREADY_EXISTS_BOOKMARK, LogLevel.WARN),
INVALID_ID_TOKEN(HttpStatus.UNAUTHORIZED, ErrorCode.INVALID_ID_TOKEN, LogLevel.WARN),
INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, ErrorCode.INVALID_AUTH_TOKEN, LogLevel.WARN),
INVALID_LOGOUT_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, ErrorCode.INVALID_LOGOUT_AUTH_TOKEN, LogLevel.WARN),
BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND_BOOKMARK, LogLevel.WARN),
GENERATE_CLIENT_SECRET_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.GENERATE_CLIENT_SECRET_FAIL,
LogLevel.ERROR), UNLINK_SOCIAL_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.UNLINK_SOCIAL_FAIL, LogLevel.WARN),
FETCH_SOCIAL_TOKEN_FAIL(HttpStatus.BAD_REQUEST, ErrorCode.FETCH_SOCIAL_TOKEN_FAIL, LogLevel.WARN),
SOCIAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.SOCIAL_API_ERROR, LogLevel.WARN),
MISSING_PARAMETER(HttpStatus.BAD_REQUEST, ErrorCode.MISSING_PARAMETER, LogLevel.WARN),;
MISSING_PARAMETER(HttpStatus.BAD_REQUEST, ErrorCode.MISSING_PARAMETER, LogLevel.WARN),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR);

private final HttpStatus status;

Expand Down
1 change: 0 additions & 1 deletion capturecat-core/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ logging:
springframework.security: DEBUG

jwt:
secret: capture_cat_jwt_secret_key_capture_cat_jwt_secret_key
access-token-expiration: 3600000
refresh-token-expiration: 2592000000

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.payload.JsonFieldType;
Expand Down Expand Up @@ -37,25 +38,42 @@ void setUp() {
}

@Test
@DisplayName("POST /logout - 헤더에 토큰이 있으면 200 OK")
void 로그아웃() {
//given
// given
String accessTokenHeader = "valid-access-token-header";
String refreshTokenHeader = "valid-refresh-token-header";
willDoNothing().given(tokenService).revokeUserTokens(anyString(), anyString());

//when & then
// when & then
given()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
.accept(org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
.header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + accessTokenHeader)
.header(JwtUtil.REFRESH_TOKEN_HEADER, JwtUtil.BEARER_PREFIX + refreshTokenHeader)
.when().post("/logout")
.then().statusCode(HttpStatus.SC_OK)
.apply(document("logout", requestPreprocessor(), responsePreprocessor(),
.when()
.post("/logout")
.then()
.statusCode(org.apache.http.HttpStatus.SC_OK)
// ApiResponse.success()의 스키마에 맞춰 본문 필드 검증 (필요 시 조정)
.body("result", org.hamcrest.Matchers.equalTo("SUCCESS"))
.apply(document("logout",
requestPreprocessor(),
responsePreprocessor(),
requestHeaders(
headerWithName(HttpHeaders.AUTHORIZATION)
.description("엑세스 토큰 (Bearer prefix 포함)"),
headerWithName(JwtUtil.REFRESH_TOKEN_HEADER)
.description("리프레시 토큰 (Bearer prefix 포함)")),
.description("리프레시 토큰 (Bearer prefix 포함)")
),
responseFields(
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과"))));
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과")
)
));

// revokeUserTokens가 단 한 번 호출되었는지 확인
then(tokenService).should(org.mockito.Mockito.times(1))
.revokeUserTokens(anyString(), anyString());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ void setUp() {

@Test
void 로그아웃_에러_코드_문서() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(INVALID_REFRESH_TOKEN,
REFRESH_TOKEN_EXPIRED);
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(INVALID_LOGOUT_AUTH_TOKEN,
INTERNAL_SERVER_ERROR);
generateErrorDocs("errorCode/logout", errorCodeDescriptors);
}

Expand Down Expand Up @@ -142,7 +142,7 @@ void setUp() {
@Test
void 회원탈퇴_에러_코드_문서() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_NOT_FOUND,
UNLINK_SOCIAL_FAIL);
UNLINK_SOCIAL_FAIL, INVALID_ACCESS_TOKEN, INVALID_REFRESH_TOKEN, INTERNAL_SERVER_ERROR);
generateErrorDocs("errorCode/withdraw", errorCodeDescriptors);
}

Expand Down
Loading