diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java index 81b007ca..67e6820a 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserLogoutFlowE2eTest.java @@ -23,8 +23,8 @@ class UserLogoutFlowE2eTest extends E2eTestSupport { @SuppressWarnings("unchecked") @Disabled - @DisplayName("정상 로그아웃 전체 플로우 - TDD REd 단계") - void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Exception { + @DisplayName("정상 로그아웃 전체 플로우 - TDD Red 단계") + void completeUserLogoutFlow_shouldFailBecauseApiNotImplemented() throws Exception { logStep(1, "관리자 로그인 (최우선)"); // 1. 관리자 로그인으로 인증 상태 확립 @@ -45,33 +45,16 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 인증 상태 확립 완료"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "로그인 상태에서 보호된 리소스 접근 확인"); - // 로그인 응답에서 세션 쿠키 추출 - String sessionCookie = null; - java.util.List cookies = loginResponse.getHeaders().get("Set-Cookie"); - if (cookies != null) { - for (String cookie : cookies) { - if (cookie.startsWith("JSESSIONID")) { - sessionCookie = cookie.split(";")[0]; // JSESSIONID=XXX 부분만 추출 - break; - } - } - } - // 2. 로그인된 상태에서 본인 프로필 조회로 인증 상태 확인 // /v0/users/me는 인증된 사용자만 접근 가능한 일반적인 API - HttpHeaders authenticatedHeaders = new HttpHeaders(); - if (sessionCookie != null) { - authenticatedHeaders.set("Cookie", sessionCookie); - } - - HttpEntity authenticatedEntity = new HttpEntity<>(authenticatedHeaders); + // 쿠키는 인터셉터에 의해 자동으로 전송됨 ResponseEntity beforeLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, authenticatedEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); @@ -79,51 +62,64 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex logSuccess("인증된 상태에서 본인 프로필 조회 성공"); - // 3. 로그아웃 API 호출 + logStep(3, "로그아웃 API 호출"); + + // 3. 로그아웃 API 호출 (세션 쿠키는 인터셉터가 자동 처리) HttpHeaders logoutHeaders = new HttpHeaders(); logoutHeaders.setContentType(MediaType.APPLICATION_JSON); logoutHeaders.set("Origin", "https://admin.icebang.site"); logoutHeaders.set("Referer", "https://admin.icebang.site/"); - // 로그아웃 요청에도 세션 쿠키 포함 - if (sessionCookie != null) { - logoutHeaders.set("Cookie", sessionCookie); - } - HttpEntity> logoutEntity = new HttpEntity<>(new HashMap<>(), logoutHeaders); try { ResponseEntity logoutResponse = restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); - logStep(4, "로그아웃 응답 검증 (API구현 돼있으면)"); + + logStep(4, "로그아웃 응답 검증"); + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) logoutResponse.getBody().get("success")).isTrue(); logSuccess("로그아웃 API 호출 성공"); logStep(5, "로그아웃 후 인증 무효화 확인"); + // 로그아웃 후 세션 쿠키 상태 확인 + logDebug("로그아웃 후 세션 쿠키: " + getSessionCookies()); + // 5. 로그아웃 후 동일한 프로필 API 접근 시 인증 실패 확인 - HttpEntity afterLogoutEntity = new HttpEntity<>(authenticatedHeaders); ResponseEntity afterLogoutResponse = - restTemplate.exchange( - getV0ApiUrl("/users/me"), HttpMethod.GET, afterLogoutEntity, Map.class); + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); // 핵심 검증: 로그아웃 후에는 인증 실패로 401 또는 403 응답이어야 함 assertThat(afterLogoutResponse.getStatusCode()) .withFailMessage( "로그아웃 후 프로필 접근이 차단되어야 합니다. 현재 상태코드: %s", afterLogoutResponse.getStatusCode()) .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); - logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); - logCompletion("일반 사용자 로그아웃 플로우"); + logSuccess("로그아웃 후 프로필 접근 차단 확인 - 인증 무효화 성공"); + logCompletion("관리자 로그아웃 플로우"); } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { - logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found"); - logError("에러 메시지 : " + ex.getMessage()); + logError("예상된 실패: 로그아웃 API가 구현되지 않음 (404 Not Found)"); + logError("에러 메시지: " + ex.getMessage()); + logError("TDD Red 단계 - API 구현 필요"); fail( "로그아웃 API (/v0/auth/logout)가 구현되지 않았습니다. " + "다음 단계에서 API를 구현해야 합니다. 에러: " + ex.getMessage()); + + } catch (org.springframework.web.client.HttpClientErrorException ex) { + logError("HTTP 클라이언트 에러: " + ex.getStatusCode() + " - " + ex.getMessage()); + + if (ex.getStatusCode() == HttpStatus.METHOD_NOT_ALLOWED) { + logError("로그아웃 엔드포인트는 존재하지만 POST 메서드를 지원하지 않습니다."); + fail("로그아웃 API가 POST 메서드를 지원하지 않습니다. 구현을 확인해주세요."); + } else { + fail("로그아웃 API 호출 중 HTTP 에러 발생: " + ex.getStatusCode() + " - " + ex.getMessage()); + } + } catch (Exception ex) { logError("예상치 못한 오류 발생: " + ex.getClass().getSimpleName()); logError("에러 메시지: " + ex.getMessage()); @@ -133,7 +129,63 @@ void completeUserRegistrationFlow_shouldFailBecauseApiNotImplemented() throws Ex } } - /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 관리자가 아닌 콘텐츠팀장으로 로그인 */ + @SuppressWarnings("unchecked") + @DisplayName("일반 사용자 로그아웃 플로우 테스트") + void regularUserLogoutFlow() throws Exception { + logStep(1, "일반 사용자 로그인"); + + // 세션 쿠키 초기화 + clearSessionCookies(); + + // 일반 사용자 로그인 수행 + performRegularUserLogin(); + + logStep(2, "일반 사용자 권한으로 프로필 조회"); + + // 로그인된 상태에서 프로필 조회 + ResponseEntity beforeLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(beforeLogoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) beforeLogoutResponse.getBody().get("success")).isTrue(); + + logSuccess("일반 사용자 프로필 조회 성공"); + + logStep(3, "일반 사용자 로그아웃 시도"); + + try { + HttpHeaders logoutHeaders = new HttpHeaders(); + logoutHeaders.setContentType(MediaType.APPLICATION_JSON); + logoutHeaders.set("Origin", "https://admin.icebang.site"); + logoutHeaders.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> logoutEntity = + new HttpEntity<>(new HashMap<>(), logoutHeaders); + + ResponseEntity logoutResponse = + restTemplate.postForEntity(getV0ApiUrl("/auth/logout"), logoutEntity, Map.class); + + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + logSuccess("일반 사용자 로그아웃 성공"); + + logStep(4, "로그아웃 후 접근 권한 무효화 확인"); + + ResponseEntity afterLogoutResponse = + restTemplate.getForEntity(getV0ApiUrl("/users/me"), Map.class); + + assertThat(afterLogoutResponse.getStatusCode()) + .isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); + + logSuccess("일반 사용자 로그아웃 후 접근 차단 확인"); + logCompletion("일반 사용자 로그아웃 플로우"); + + } catch (org.springframework.web.client.HttpClientErrorException.NotFound ex) { + logError("예상된 실패: 로그아웃 API 미구현"); + fail("로그아웃 API가 구현되지 않았습니다: " + ex.getMessage()); + } + } + + /** 일반 사용자 로그인을 수행하는 헬퍼 메서드 - 관리자가 아닌 콘텐츠팀장으로 로그인 */ private void performRegularUserLogin() { Map loginRequest = new HashMap<>(); loginRequest.put("email", "viral.jung@icebang.site"); @@ -154,6 +206,34 @@ private void performRegularUserLogin() { throw new RuntimeException("Regular user login failed for logout test"); } - logSuccess("일반 사용자 로그인 완료 (로그아웃 테스트용)"); + logSuccess("일반 사용자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("일반 사용자 세션 쿠키: " + getSessionCookies()); + } + + /** 관리자 로그인을 수행하는 헬퍼 메서드 */ + private void performAdminLogin() { + clearSessionCookies(); // 기존 세션 정리 + + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Origin", "https://admin.icebang.site"); + headers.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + logError("관리자 로그인 실패: " + response.getStatusCode()); + throw new RuntimeException("Admin login failed"); + } + + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("관리자 세션 쿠키: " + getSessionCookies()); } } diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java index df66a7c6..1bc1903b 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/UserRegistrationFlowE2eTest.java @@ -44,11 +44,12 @@ void completeUserRegistrationFlow() throws Exception { assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) loginResponse.getBody().get("success")).isTrue(); - logSuccess("관리자 로그인 성공 - 이제 모든 리소스 접근 가능"); + logSuccess("관리자 로그인 성공 - 세션 쿠키 자동 저장됨"); + logDebug("현재 세션 쿠키: " + getSessionCookies()); logStep(2, "조직 목록 조회 (인증된 상태)"); - // 2. 조직 목록 조회 (로그인 후 가능) + // 2. 조직 목록 조회 (로그인 후 가능, 쿠키 자동 전송) ResponseEntity organizationsResponse = restTemplate.getForEntity(getV0ApiUrl("/organizations"), Map.class); @@ -56,7 +57,7 @@ void completeUserRegistrationFlow() throws Exception { assertThat((Boolean) organizationsResponse.getBody().get("success")).isTrue(); assertThat(organizationsResponse.getBody().get("data")).isNotNull(); - logSuccess("조직 목록 조회 성공"); + logSuccess("조직 목록 조회 성공 (인증된 요청)"); logStep(3, "부서 및 각종 데이터 조회 (특정 조직 옵션)"); @@ -229,7 +230,8 @@ private void performAdminLogin() { throw new RuntimeException("Admin login failed"); } - logSuccess("관리자 로그인 완료"); + logSuccess("관리자 로그인 완료 - 세션 쿠키 저장됨"); + logDebug("세션 쿠키: " + getSessionCookies()); } /** 사용자 등록을 수행하는 헬퍼 메서드 */ diff --git a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java index c2d10870..97d1cf0d 100644 --- a/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java +++ b/apps/user-service/src/test/java/site/icebang/e2e/setup/support/E2eTestSupport.java @@ -1,14 +1,21 @@ package site.icebang.e2e.setup.support; +import java.util.ArrayList; +import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Import; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; + import site.icebang.e2e.setup.annotation.E2eTest; import site.icebang.e2e.setup.config.E2eTestConfiguration; @@ -26,6 +33,53 @@ public abstract class E2eTestSupport { protected MockMvc mockMvc; + private List sessionCookies = new ArrayList<>(); + + @PostConstruct + void setupCookieManagement() { + // RestTemplate에 쿠키 인터셉터 추가 + restTemplate.getRestTemplate().getInterceptors().add(createCookieInterceptor()); + logDebug("쿠키 관리 인터셉터 설정 완료"); + } + + private ClientHttpRequestInterceptor createCookieInterceptor() { + return (request, body, execution) -> { + // 요청에 저장된 쿠키 추가 + if (!sessionCookies.isEmpty()) { + request.getHeaders().put("Cookie", sessionCookies); + logDebug("쿠키 전송: " + String.join("; ", sessionCookies)); + } + + // 요청 실행 + ClientHttpResponse response = execution.execute(request, body); + + // 응답에서 Set-Cookie 헤더 추출하여 저장 + List setCookieHeaders = response.getHeaders().get("Set-Cookie"); + if (setCookieHeaders != null && !setCookieHeaders.isEmpty()) { + updateSessionCookies(setCookieHeaders); + logDebug("세션 쿠키 업데이트: " + String.join("; ", sessionCookies)); + } + + return response; + }; + } + + private void updateSessionCookies(List setCookieHeaders) { + for (String setCookie : setCookieHeaders) { + // 쿠키 이름 추출 + String cookieName = setCookie.split("=")[0]; + String cookieValue = setCookie.split(";")[0]; // 쿠키 값만 추출 (속성 제외) + + // 같은 이름의 쿠키가 있으면 제거 + sessionCookies.removeIf(cookie -> cookie.startsWith(cookieName + "=")); + + // 새 쿠키 추가 (빈 값이 아닌 경우만) + if (!cookieValue.endsWith("=")) { + sessionCookies.add(cookieValue); + } + } + } + protected String getBaseUrl() { return "http://localhost:" + port; } @@ -38,6 +92,20 @@ protected String getV0ApiUrl(String path) { return getBaseUrl() + "/v0" + path; } + /** 세션 쿠키 관리 메서드들 */ + protected void clearSessionCookies() { + sessionCookies.clear(); + logDebug("세션 쿠키 초기화됨"); + } + + protected List getSessionCookies() { + return new ArrayList<>(sessionCookies); + } + + protected boolean hasSessionCookie(String cookieName) { + return sessionCookies.stream().anyMatch(cookie -> cookie.startsWith(cookieName + "=")); + } + /** 테스트 시나리오 단계별 로깅을 위한 유틸리티 메서드 */ protected void logStep(int stepNumber, String description) { System.out.println(String.format("📋 Step %d: %s", stepNumber, description)); @@ -57,4 +125,16 @@ protected void logError(String message) { protected void logCompletion(String scenario) { System.out.println(String.format("🎉 %s 시나리오 완료!", scenario)); } + + /** 디버그 로깅을 위한 유틸리티 메서드 */ + protected void logDebug(String message) { + if (isDebugEnabled()) { + System.out.println("🐛 DEBUG: " + message); + } + } + + private boolean isDebugEnabled() { + return System.getProperty("test.debug", "false").equals("true") + || System.getProperty("e2e.debug", "false").equals("true"); + } }