Skip to content

Commit deb4833

Browse files
authored
Merge pull request #328 from prgrms-web-devcourse-final-project/develop
배포
2 parents 0b17b27 + b094b0c commit deb4833

File tree

6 files changed

+256
-12
lines changed

6 files changed

+256
-12
lines changed

backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ public class CookieUtil {
2626

2727
// 쿠키 보안 설정 상수
2828
private static final boolean HTTP_ONLY = true;
29-
private static final boolean SECURE_IN_PRODUCTION = false; // 개발환경에서는 false (HTTP), 운영환경에서는 true로 변경 (HTTPS)
3029
private static final String COOKIE_PATH = "/";
31-
private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용
3230
private static final int COOKIE_EXPIRE_IMMEDIATELY = 0;
3331

3432
@Value("${custom.cookie.domain:}")
3533
private String cookieDomain;
3634

35+
@Value("${custom.cookie.secure:false}")
36+
private boolean cookieSecure;
37+
38+
@Value("${custom.cookie.same-site:Lax}")
39+
private String cookieSameSite;
40+
3741
public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) {
3842
setAccessTokenCookie(response, accessToken);
3943
setRefreshTokenCookie(response, refreshToken);
@@ -58,26 +62,26 @@ public void clearTokenCookies(HttpServletResponse response) {
5862
* ResponseCookie를 생성합니다 (SameSite 지원).
5963
*/
6064
private ResponseCookie createResponseCookie(String name, String value, int maxAge) {
61-
log.debug("=== 쿠키 생성 중: name={}, cookieDomain='{}', isEmpty={}",
62-
name, cookieDomain, cookieDomain == null || cookieDomain.isEmpty());
65+
log.info("=== 쿠키 생성 중: name={}, domain='{}', secure={}, sameSite={}",
66+
name, cookieDomain, cookieSecure, cookieSameSite);
6367

6468
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value)
6569
.httpOnly(HTTP_ONLY)
66-
.secure(SECURE_IN_PRODUCTION)
70+
.secure(cookieSecure)
6771
.path(COOKIE_PATH)
6872
.maxAge(Duration.ofSeconds(maxAge))
69-
.sameSite(SAME_SITE);
73+
.sameSite(cookieSameSite);
7074

7175
// 도메인이 설정되어 있으면 추가
7276
if (cookieDomain != null && !cookieDomain.isEmpty()) {
73-
log.debug("쿠키 도메인 설정: {}", cookieDomain);
77+
log.info("쿠키 도메인 설정: {}", cookieDomain);
7478
builder.domain(cookieDomain);
7579
} else {
76-
log.debug("쿠키 도메인 설정 안 함 (빈 값 또는 null)");
80+
log.info("쿠키 도메인 설정 안 함 (빈 값 또는 null)");
7781
}
7882

7983
ResponseCookie cookie = builder.build();
80-
log.debug("생성된 쿠키: {}", cookie);
84+
log.info("생성된 쿠키: {}", cookie);
8185
return cookie;
8286
}
8387

backend/src/main/resources/application-dev.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,6 @@ custom:
7171
frontend:
7272
url: ${DEV_FRONTEND_URL}
7373
cookie:
74-
domain: ${DEV_COOKIE_DOMAIN}
74+
domain: ${DEV_COOKIE_DOMAIN:} # 개발환경: 도메인 설정 없음 (localhost)
75+
secure: false # HTTP 환경 (localhost)
76+
same-site: Lax # 개발환경에서는 Lax로 충분

backend/src/main/resources/application-prod.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ custom:
8484
url: ${PROD_FRONTEND_URL}
8585
cookie:
8686
domain: ${PROD_COOKIE_DOMAIN:.trybalaw.com} # 운영환경: 모든 서브도메인에서 쿠키 공유
87+
secure: true # HTTPS 환경에서는 반드시 true
88+
same-site: None # 크로스 도메인 쿠키 전송 허용 (api.trybalaw.com <-> www.trybalaw.com)
8789

8890
sentry:
8991
dsn: ${PROD_SENTRY_DSN}

backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import org.junit.jupiter.api.Test;
99
import org.junit.jupiter.api.extension.ExtendWith;
1010
import org.mockito.ArgumentCaptor;
11-
import org.mockito.InjectMocks;
1211
import org.mockito.Mock;
1312
import org.mockito.junit.jupiter.MockitoExtension;
1413
import org.slf4j.Logger;
@@ -32,7 +31,6 @@ class CookieUtilTest {
3231
@Mock
3332
private HttpServletResponse response;
3433

35-
@InjectMocks
3634
private CookieUtil cookieUtil;
3735

3836
private static final String ACCESS_TOKEN = "testAccessToken";
@@ -43,6 +41,12 @@ class CookieUtilTest {
4341
@BeforeEach
4442
void setUp() {
4543
log.info("=== 테스트 초기화 ===");
44+
cookieUtil = new CookieUtil();
45+
// 테스트 환경 설정: 개발 환경 (HTTP, SameSite=Lax)
46+
org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieDomain", "");
47+
org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSecure", false);
48+
org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSameSite", "Lax");
49+
log.info("CookieUtil 설정 완료: domain='', secure=false, sameSite=Lax");
4650
}
4751

4852
@Test
@@ -330,4 +334,45 @@ void cookieMaxAgeAttribute_ExpiryTime() {
330334

331335
log.info("=== 토큰 만료 시간 테스트 완료 ===");
332336
}
337+
338+
@Test
339+
@DisplayName("프로덕션 환경 - Secure=true, SameSite=None, Domain 설정")
340+
void productionCookieSettings() {
341+
// given
342+
log.info("=== 프로덕션 환경 쿠키 설정 테스트 시작 ===");
343+
CookieUtil prodCookieUtil = new CookieUtil();
344+
org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieDomain", ".trybalaw.com");
345+
org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSecure", true);
346+
org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSameSite", "None");
347+
log.info("프로덕션 설정: domain=.trybalaw.com, secure=true, sameSite=None");
348+
349+
// when
350+
prodCookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN);
351+
352+
// then
353+
ArgumentCaptor<String> headerCaptor = ArgumentCaptor.forClass(String.class);
354+
verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture());
355+
356+
var setCookieHeaders = headerCaptor.getAllValues();
357+
358+
// 액세스 토큰 쿠키 검증
359+
String accessCookieHeader = setCookieHeaders.getFirst();
360+
assertThat(accessCookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN);
361+
assertThat(accessCookieHeader).contains("HttpOnly");
362+
assertThat(accessCookieHeader).contains("Secure");
363+
assertThat(accessCookieHeader).contains("Domain=.trybalaw.com");
364+
assertThat(accessCookieHeader).contains("SameSite=None");
365+
log.info("프로덕션 액세스 토큰 쿠키 검증 완료: {}", accessCookieHeader);
366+
367+
// 리프레시 토큰 쿠키 검증
368+
String refreshCookieHeader = setCookieHeaders.get(1);
369+
assertThat(refreshCookieHeader).contains(REFRESH_TOKEN_NAME + "=" + REFRESH_TOKEN);
370+
assertThat(refreshCookieHeader).contains("HttpOnly");
371+
assertThat(refreshCookieHeader).contains("Secure");
372+
assertThat(refreshCookieHeader).contains("Domain=.trybalaw.com");
373+
assertThat(refreshCookieHeader).contains("SameSite=None");
374+
log.info("프로덕션 리프레시 토큰 쿠키 검증 완료: {}", refreshCookieHeader);
375+
376+
log.info("=== 프로덕션 환경 쿠키 설정 테스트 완료 ===");
377+
}
333378
}

backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,22 @@ private Map<String, Object> createNaverAttributes() {
9696

9797
return attributes;
9898
}
99+
100+
@Test
101+
@DisplayName("OAuth2 회원 저장 후 반환된 엔티티 사용 - memberId 할당 확인")
102+
void oauth2MemberSave_ReturnsEntityWithMemberId() {
103+
// given - 이 테스트는 CustomOAuth2UserService에서 save() 반환값을 사용하는지 검증
104+
// 실제 구현에서는 다음과 같이 수정되어야 함:
105+
// member = oauth2MemberRepository.save(member);
106+
107+
// when - save() 호출 시 memberId가 할당된 엔티티가 반환됨
108+
// JPA의 @GeneratedValue 전략 사용 시, save()는 영속화된 엔티티를 반환하며
109+
// 이 엔티티에는 자동 생성된 ID가 포함되어 있음
110+
111+
// then - 반환된 엔티티의 memberId를 사용해야 JWT 토큰 생성 시 올바른 ID가 포함됨
112+
// 이를 통해 소셜 로그인 후 API 호출 시 member_id 조회가 정상 동작함
113+
114+
// 이 테스트는 문서화 목적으로, 실제 동작은 Integration Test에서 검증됨
115+
assertThat(true).isTrue(); // 개념 검증용 테스트
116+
}
99117
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package com.ai.lawyer.global.util;
2+
3+
import com.ai.lawyer.domain.member.entity.Member;
4+
import com.ai.lawyer.domain.member.entity.OAuth2Member;
5+
import com.ai.lawyer.domain.member.repositories.MemberRepository;
6+
import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.extension.ExtendWith;
11+
import org.mockito.Mock;
12+
import org.mockito.junit.jupiter.MockitoExtension;
13+
import org.springframework.web.server.ResponseStatusException;
14+
15+
import java.util.Optional;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
19+
import static org.mockito.BDDMockito.given;
20+
import static org.mockito.Mockito.verify;
21+
22+
@ExtendWith(MockitoExtension.class)
23+
@DisplayName("AuthUtil 테스트")
24+
class AuthUtilTest {
25+
26+
@Mock
27+
private MemberRepository memberRepository;
28+
29+
@Mock
30+
private OAuth2MemberRepository oauth2MemberRepository;
31+
32+
private Member localMember;
33+
private OAuth2Member oauth2Member;
34+
35+
@BeforeEach
36+
void setUp() {
37+
AuthUtil authUtil = new AuthUtil(memberRepository);
38+
authUtil.setOauth2MemberRepository(oauth2MemberRepository);
39+
40+
localMember = Member.builder()
41+
.memberId(1L)
42+
.loginId("[email protected]")
43+
.password("encodedPassword")
44+
.name("로컬사용자")
45+
.age(30)
46+
.gender(Member.Gender.MALE)
47+
.role(Member.Role.USER)
48+
.build();
49+
50+
oauth2Member = OAuth2Member.builder()
51+
.memberId(2L)
52+
.loginId("[email protected]")
53+
54+
.name("소셜사용자")
55+
.age(25)
56+
.gender(Member.Gender.FEMALE)
57+
.provider(OAuth2Member.Provider.KAKAO)
58+
.providerId("kakao123")
59+
.role(Member.Role.USER)
60+
.build();
61+
}
62+
63+
@Test
64+
@DisplayName("로컬 회원 조회 성공")
65+
void getMemberOrThrow_LocalMember_Success() {
66+
// given
67+
Long memberId = 1L;
68+
given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember));
69+
70+
// when
71+
Member result = AuthUtil.getMemberOrThrow(memberId);
72+
73+
// then
74+
assertThat(result).isNotNull();
75+
assertThat(result.getMemberId()).isEqualTo(1L);
76+
assertThat(result.getLoginId()).isEqualTo("[email protected]");
77+
assertThat(result.getName()).isEqualTo("로컬사용자");
78+
79+
verify(memberRepository).findById(memberId);
80+
}
81+
82+
@Test
83+
@DisplayName("OAuth2 회원 조회 성공 - Member 테이블에 없을 때")
84+
void getMemberOrThrow_OAuth2Member_Success() {
85+
// given
86+
Long memberId = 2L;
87+
given(memberRepository.findById(memberId)).willReturn(Optional.empty());
88+
given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member));
89+
90+
// when
91+
Member result = AuthUtil.getMemberOrThrow(memberId);
92+
93+
// then
94+
assertThat(result).isNotNull();
95+
assertThat(result.getMemberId()).isEqualTo(2L);
96+
assertThat(result.getLoginId()).isEqualTo("[email protected]");
97+
assertThat(result.getName()).isEqualTo("소셜사용자");
98+
assertThat(result.getAge()).isEqualTo(25);
99+
assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE);
100+
assertThat(result.getRole()).isEqualTo(Member.Role.USER);
101+
102+
verify(memberRepository).findById(memberId);
103+
verify(oauth2MemberRepository).findById(memberId);
104+
}
105+
106+
@Test
107+
@DisplayName("OAuth2 회원을 Member로 변환 - 비밀번호는 빈 문자열")
108+
void getMemberOrThrow_OAuth2Member_NoPassword() {
109+
// given
110+
Long memberId = 2L;
111+
given(memberRepository.findById(memberId)).willReturn(Optional.empty());
112+
given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member));
113+
114+
// when
115+
Member result = AuthUtil.getMemberOrThrow(memberId);
116+
117+
// then
118+
assertThat(result.getPassword()).isEqualTo("");
119+
}
120+
121+
@Test
122+
@DisplayName("회원을 찾을 수 없을 때 예외 발생")
123+
void getMemberOrThrow_MemberNotFound_ThrowsException() {
124+
// given
125+
Long memberId = 999L;
126+
given(memberRepository.findById(memberId)).willReturn(Optional.empty());
127+
given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty());
128+
129+
// when & then
130+
assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId))
131+
.isInstanceOf(ResponseStatusException.class)
132+
.hasMessageContaining("회원 정보를 찾을 수 없습니다");
133+
134+
verify(memberRepository).findById(memberId);
135+
verify(oauth2MemberRepository).findById(memberId);
136+
}
137+
138+
@Test
139+
@DisplayName("로컬 회원 우선 조회 - 양쪽 테이블에 같은 ID가 있을 때")
140+
void getMemberOrThrow_PrioritizeLocalMember() {
141+
// given
142+
Long memberId = 1L;
143+
given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember));
144+
// OAuth2 repository는 호출되지 않아야 함
145+
146+
// when
147+
Member result = AuthUtil.getMemberOrThrow(memberId);
148+
149+
// then
150+
assertThat(result).isNotNull();
151+
assertThat(result.getLoginId()).isEqualTo("[email protected]");
152+
153+
verify(memberRepository).findById(memberId);
154+
// OAuth2 repository는 호출되지 않음을 검증
155+
org.mockito.Mockito.verifyNoInteractions(oauth2MemberRepository);
156+
}
157+
158+
@Test
159+
@DisplayName("OAuth2MemberRepository가 null일 때도 정상 동작")
160+
void getMemberOrThrow_NullOAuth2Repository() {
161+
// given
162+
Long memberId = 1L;
163+
// OAuth2 repository를 설정하지 않음
164+
given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember));
165+
166+
// when
167+
Member result = AuthUtil.getMemberOrThrow(memberId);
168+
169+
// then
170+
assertThat(result).isNotNull();
171+
assertThat(result.getLoginId()).isEqualTo("[email protected]");
172+
}
173+
}

0 commit comments

Comments
 (0)