Skip to the content.

Security Test Guide — 보안 컴포넌트 테스트 전략

목적: Security 관련 컴포넌트의 테스트 작성 가이드

철학: TDD 기반, 보안 시나리오 완전 검증, 업계 표준 준수


1️⃣ 테스트 범위

컴포넌트별 테스트 유형

컴포넌트 단위 테스트 통합 테스트 테스트 대상
ApiPaths - 경로 상수 값 검증
SecurityPaths - 배열 포함 관계 검증
JwtAuthenticationFilter 토큰 검증, Silent Refresh
AuthenticationErrorHandler - RFC 7807 응답 형식
TokenCookieWriter - 쿠키 속성 검증
SecurityConfig - 인가 규칙 동작
Method Security - @PreAuthorize 동작

2️⃣ ApiPaths 테스트

경로 상수 값 검증

package com.company.adapter.in.rest.auth.paths;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * ApiPaths 단위 테스트
 *
 * <p>경로 상수 값이 올바르게 정의되었는지 검증합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
@DisplayName("ApiPaths 테스트")
class ApiPathsTest {

    @Test
    @DisplayName("API V1 베이스 경로는 /api/v1이어야 한다")
    void api_v1_base_path() {
        assertThat(ApiPaths.API_V1).isEqualTo("/api/v1");
    }

    @Nested
    @DisplayName("Members 경로")
    class MembersPathsTest {

        @Test
        @DisplayName("Members BASE 경로는 /api/v1/members이어야 한다")
        void members_base_path() {
            assertThat(ApiPaths.Members.BASE).isEqualTo("/api/v1/members");
        }

        @Test
        @DisplayName("Members BY_ID 경로는 PathVariable을 포함해야 한다")
        void members_by_id_contains_path_variable() {
            assertThat(ApiPaths.Members.BY_ID)
                .startsWith(ApiPaths.Members.BASE)
                .contains("{id}");
        }

        @Test
        @DisplayName("Members 경로들은 BASE 경로로 시작해야 한다")
        void members_paths_start_with_base() {
            assertThat(ApiPaths.Members.REGISTER).startsWith(ApiPaths.Members.BASE);
            assertThat(ApiPaths.Members.BY_ID).startsWith(ApiPaths.Members.BASE);
            assertThat(ApiPaths.Members.PASSWORD_RESET).startsWith(ApiPaths.Members.BASE);
        }
    }

    @Nested
    @DisplayName("Auth 경로")
    class AuthPathsTest {

        @Test
        @DisplayName("Auth BASE 경로는 /api/v1/auth이어야 한다")
        void auth_base_path() {
            assertThat(ApiPaths.Auth.BASE).isEqualTo("/api/v1/auth");
        }

        @Test
        @DisplayName("Auth 경로들은 BASE 경로로 시작해야 한다")
        void auth_paths_start_with_base() {
            assertThat(ApiPaths.Auth.LOGIN).startsWith(ApiPaths.Auth.BASE);
            assertThat(ApiPaths.Auth.LOGOUT).startsWith(ApiPaths.Auth.BASE);
            assertThat(ApiPaths.Auth.REFRESH).startsWith(ApiPaths.Auth.BASE);
        }
    }

    @Nested
    @DisplayName("Orders 경로")
    class OrdersPathsTest {

        @Test
        @DisplayName("Orders BASE 경로는 /api/v1/orders이어야 한다")
        void orders_base_path() {
            assertThat(ApiPaths.Orders.BASE).isEqualTo("/api/v1/orders");
        }

        @Test
        @DisplayName("Orders 액션 경로는 PathVariable과 액션을 포함해야 한다")
        void orders_action_paths() {
            assertThat(ApiPaths.Orders.CANCEL)
                .startsWith(ApiPaths.Orders.BASE)
                .contains("{id}")
                .endsWith("/cancel");

            assertThat(ApiPaths.Orders.CONFIRM)
                .startsWith(ApiPaths.Orders.BASE)
                .contains("{id}")
                .endsWith("/confirm");
        }
    }
}

3️⃣ SecurityPaths 테스트

보안 그룹 포함 관계 검증

package com.company.adapter.in.rest.auth.paths;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * SecurityPaths 단위 테스트
 *
 * <p>보안 정책별 경로 그룹이 올바르게 정의되었는지 검증합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
@DisplayName("SecurityPaths 테스트")
class SecurityPathsTest {

    @Test
    @DisplayName("PUBLIC_ENDPOINTS에 로그인 경로가 포함되어야 한다")
    void public_endpoints_contains_login() {
        assertThat(SecurityPaths.PUBLIC_ENDPOINTS)
            .contains(ApiPaths.Auth.LOGIN);
    }

    @Test
    @DisplayName("PUBLIC_ENDPOINTS에 회원가입 경로가 포함되어야 한다")
    void public_endpoints_contains_register() {
        assertThat(SecurityPaths.PUBLIC_ENDPOINTS)
            .contains(ApiPaths.Members.REGISTER);
    }

    @Test
    @DisplayName("PUBLIC_ENDPOINTS에 토큰 갱신 경로가 포함되어야 한다")
    void public_endpoints_contains_refresh() {
        assertThat(SecurityPaths.PUBLIC_ENDPOINTS)
            .contains(ApiPaths.Auth.REFRESH);
    }

    @Test
    @DisplayName("PUBLIC_PATTERNS에 Swagger 경로가 포함되어야 한다")
    void public_patterns_contains_swagger() {
        assertThat(SecurityPaths.PUBLIC_PATTERNS)
            .anyMatch(pattern -> pattern.contains("swagger"));
    }

    @Test
    @DisplayName("PUBLIC_PATTERNS에 Actuator 경로가 포함되어야 한다")
    void public_patterns_contains_actuator() {
        assertThat(SecurityPaths.PUBLIC_PATTERNS)
            .anyMatch(pattern -> pattern.contains("actuator"));
    }

    @Test
    @DisplayName("ADMIN_PATTERNS에 /admin 패턴이 포함되어야 한다")
    void admin_patterns_contains_admin() {
        assertThat(SecurityPaths.ADMIN_PATTERNS)
            .anyMatch(pattern -> pattern.contains("/admin"));
    }

    @Test
    @DisplayName("OWNER_VERIFICATION_REQUIRED에 주문 취소 경로가 포함되어야 한다")
    void owner_verification_contains_order_cancel() {
        assertThat(SecurityPaths.OWNER_VERIFICATION_REQUIRED)
            .contains(ApiPaths.Orders.CANCEL);
    }

    @Test
    @DisplayName("PUBLIC_ENDPOINTS의 모든 경로는 /api로 시작해야 한다")
    void public_endpoints_start_with_api() {
        Arrays.stream(SecurityPaths.PUBLIC_ENDPOINTS)
            .forEach(path -> assertThat(path).startsWith("/api"));
    }
}

4️⃣ JwtAuthenticationFilter 테스트

단위 테스트 (Mock 기반)

package com.company.adapter.in.rest.auth.filter;

import com.company.adapter.in.rest.auth.component.MdcContextHolder;
import com.company.adapter.in.rest.auth.component.SecurityContextAuthenticator;
import com.company.adapter.in.rest.auth.component.TokenCookieWriter;
import com.company.application.member.dto.response.TokenPairResponse;
import com.company.application.member.port.out.TokenProviderPort;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.mockito.BDDMockito.*;

/**
 * JwtAuthenticationFilter 단위 테스트
 *
 * @author development-team
 * @since 1.0.0
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("JwtAuthenticationFilter 테스트")
class JwtAuthenticationFilterTest {

    @Mock
    private TokenProviderPort tokenProviderPort;

    @Mock
    private TokenCookieWriter tokenCookieWriter;

    @Mock
    private SecurityContextAuthenticator securityContextAuthenticator;

    @Mock
    private MdcContextHolder mdcContextHolder;

    @Mock
    private FilterChain filterChain;

    @InjectMocks
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    private MockHttpServletRequest request;
    private MockHttpServletResponse response;

    @BeforeEach
    void setUp() {
        request = new MockHttpServletRequest();
        response = new MockHttpServletResponse();
    }

    @Nested
    @DisplayName("Access Token 유효한 경우")
    class ValidAccessToken {

        @Test
        @DisplayName("인증 성공 후 다음 필터로 진행한다")
        void should_authenticate_and_continue() throws Exception {
            // Given
            String validToken = "valid.access.token";
            String memberId = "123";
            request.setCookies(new Cookie("access_token", validToken));

            given(tokenProviderPort.validateAccessToken(validToken)).willReturn(true);
            given(securityContextAuthenticator.authenticate(request, validToken))
                .willReturn(memberId);

            // When
            jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

            // Then
            then(securityContextAuthenticator).should().authenticate(request, validToken);
            then(mdcContextHolder).should().setMemberId(memberId);
            then(filterChain).should().doFilter(request, response);
        }
    }

    @Nested
    @DisplayName("Access Token 만료된 경우")
    class ExpiredAccessToken {

        @Test
        @DisplayName("Silent Refresh 성공 시 새 토큰으로 인증한다")
        void should_silent_refresh_when_access_token_expired() throws Exception {
            // Given
            String expiredAccessToken = "expired.access.token";
            String validRefreshToken = "valid.refresh.token";
            String memberId = "123";
            String newAccessToken = "new.access.token";
            String newRefreshToken = "new.refresh.token";

            request.setCookies(
                new Cookie("access_token", expiredAccessToken),
                new Cookie("refresh_token", validRefreshToken)
            );

            given(tokenProviderPort.validateAccessToken(expiredAccessToken)).willReturn(false);
            given(tokenProviderPort.isAccessTokenExpired(expiredAccessToken)).willReturn(true);
            given(tokenProviderPort.validateRefreshToken(validRefreshToken)).willReturn(true);
            given(tokenProviderPort.extractMemberIdFromRefreshToken(validRefreshToken))
                .willReturn(memberId);
            given(tokenProviderPort.generateTokenPair(memberId))
                .willReturn(new TokenPairResponse(newAccessToken, newRefreshToken, 3600, 604800));
            given(securityContextAuthenticator.authenticate(request, newAccessToken))
                .willReturn(memberId);

            // When
            jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

            // Then
            then(tokenCookieWriter).should().addTokenCookies(
                eq(response), eq(newAccessToken), eq(newRefreshToken), anyLong(), anyLong());
            then(securityContextAuthenticator).should().authenticate(request, newAccessToken);
            then(mdcContextHolder).should().setMemberId(memberId);
            then(filterChain).should().doFilter(request, response);
        }
    }

    @Nested
    @DisplayName("토큰 없는 경우")
    class NoToken {

        @Test
        @DisplayName("인증 없이 다음 필터로 진행한다")
        void should_continue_without_authentication() throws Exception {
            // Given
            // 토큰 없음

            // When
            jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

            // Then
            then(securityContextAuthenticator).shouldHaveNoInteractions();
            then(filterChain).should().doFilter(request, response);
        }
    }

    @Nested
    @DisplayName("Authorization 헤더 사용")
    class AuthorizationHeader {

        @Test
        @DisplayName("Bearer 토큰으로 인증 성공한다")
        void should_authenticate_with_bearer_token() throws Exception {
            // Given
            String validToken = "valid.access.token";
            String memberId = "123";
            request.addHeader("Authorization", "Bearer " + validToken);

            given(tokenProviderPort.validateAccessToken(validToken)).willReturn(true);
            given(securityContextAuthenticator.authenticate(request, validToken))
                .willReturn(memberId);

            // When
            jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

            // Then
            then(securityContextAuthenticator).should().authenticate(request, validToken);
            then(filterChain).should().doFilter(request, response);
        }
    }
}

5️⃣ AuthenticationErrorHandler 테스트

RFC 7807 응답 형식 검증

package com.company.adapter.in.rest.auth.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * AuthenticationErrorHandler 단위 테스트
 *
 * @author development-team
 * @since 1.0.0
 */
@DisplayName("AuthenticationErrorHandler 테스트")
class AuthenticationErrorHandlerTest {

    private AuthenticationErrorHandler handler;
    private ObjectMapper objectMapper;
    private MockHttpServletRequest request;
    private MockHttpServletResponse response;

    @BeforeEach
    void setUp() {
        objectMapper = new ObjectMapper();
        handler = new AuthenticationErrorHandler(objectMapper);
        request = new MockHttpServletRequest();
        response = new MockHttpServletResponse();
    }

    @Nested
    @DisplayName("인증 실패 (401)")
    class AuthenticationFailure {

        @Test
        @DisplayName("401 상태 코드를 반환한다")
        void should_return_401_status() throws Exception {
            // Given
            request.setRequestURI("/api/v1/orders");
            BadCredentialsException exception = new BadCredentialsException("Invalid credentials");

            // When
            handler.commence(request, response, exception);

            // Then
            assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
        }

        @Test
        @DisplayName("RFC 7807 ProblemDetail 형식으로 응답한다")
        void should_return_problem_detail_format() throws Exception {
            // Given
            request.setRequestURI("/api/v1/orders");
            BadCredentialsException exception = new BadCredentialsException("Invalid credentials");

            // When
            handler.commence(request, response, exception);

            // Then
            assertThat(response.getContentType())
                .isEqualTo(MediaType.APPLICATION_PROBLEM_JSON_VALUE);

            ProblemDetail problemDetail = objectMapper.readValue(
                response.getContentAsString(), ProblemDetail.class);

            assertThat(problemDetail.getStatus()).isEqualTo(401);
            assertThat(problemDetail.getTitle()).isEqualTo("Unauthorized");
            assertThat(problemDetail.getDetail()).contains("인증");
            assertThat(problemDetail.getProperties()).containsKey("code");
            assertThat(problemDetail.getProperties()).containsKey("timestamp");
        }

        @Test
        @DisplayName("instance에 요청 경로가 포함된다")
        void should_include_request_uri_in_instance() throws Exception {
            // Given
            request.setRequestURI("/api/v1/orders");
            request.setQueryString("page=1&size=10");
            BadCredentialsException exception = new BadCredentialsException("Invalid");

            // When
            handler.commence(request, response, exception);

            // Then
            ProblemDetail problemDetail = objectMapper.readValue(
                response.getContentAsString(), ProblemDetail.class);

            assertThat(problemDetail.getInstance().toString())
                .contains("/api/v1/orders")
                .contains("page=1");
        }

        @Test
        @DisplayName("MDC의 requestId가 응답에 포함된다")
        void should_include_request_id_from_mdc() throws Exception {
            // Given
            String requestId = "test-request-id";
            MDC.put("requestId", requestId);
            request.setRequestURI("/api/v1/orders");
            BadCredentialsException exception = new BadCredentialsException("Invalid");

            try {
                // When
                handler.commence(request, response, exception);

                // Then
                ProblemDetail problemDetail = objectMapper.readValue(
                    response.getContentAsString(), ProblemDetail.class);

                assertThat(problemDetail.getProperties().get("requestId"))
                    .isEqualTo(requestId);
            } finally {
                MDC.clear();
            }
        }
    }

    @Nested
    @DisplayName("인가 실패 (403)")
    class AccessDenied {

        @Test
        @DisplayName("403 상태 코드를 반환한다")
        void should_return_403_status() throws Exception {
            // Given
            request.setRequestURI("/api/v1/admin/users");
            AccessDeniedException exception = new AccessDeniedException("Access denied");

            // When
            handler.handle(request, response, exception);

            // Then
            assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());
        }

        @Test
        @DisplayName("RFC 7807 ProblemDetail 형식으로 응답한다")
        void should_return_problem_detail_format() throws Exception {
            // Given
            request.setRequestURI("/api/v1/admin/users");
            AccessDeniedException exception = new AccessDeniedException("Access denied");

            // When
            handler.handle(request, response, exception);

            // Then
            ProblemDetail problemDetail = objectMapper.readValue(
                response.getContentAsString(), ProblemDetail.class);

            assertThat(problemDetail.getStatus()).isEqualTo(403);
            assertThat(problemDetail.getTitle()).isEqualTo("Forbidden");
            assertThat(problemDetail.getDetail()).contains("권한");
            assertThat(problemDetail.getProperties().get("code")).isEqualTo("ACCESS_DENIED");
        }
    }
}

6️⃣ TokenCookieWriter 테스트

쿠키 속성 검증

package com.company.adapter.in.rest.auth.component;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * TokenCookieWriter 단위 테스트
 *
 * @author development-team
 * @since 1.0.0
 */
@DisplayName("TokenCookieWriter 테스트")
class TokenCookieWriterTest {

    private TokenCookieWriter tokenCookieWriter;
    private MockHttpServletResponse response;

    @BeforeEach
    void setUp() {
        // 테스트용 Properties 설정
        SecurityProperties properties = new SecurityProperties();
        properties.getCookie().setSecure(false);
        properties.getCookie().setDomain("localhost");
        properties.getCookie().setSameSite("lax");

        tokenCookieWriter = new TokenCookieWriter(properties);
        response = new MockHttpServletResponse();
    }

    @Nested
    @DisplayName("Access Token 쿠키")
    class AccessTokenCookie {

        @Test
        @DisplayName("HttpOnly 속성이 설정된다")
        void should_set_httpOnly_attribute() {
            // When
            tokenCookieWriter.addAccessTokenCookie(response, "token", 3600);

            // Then
            String cookie = response.getHeader("Set-Cookie");
            assertThat(cookie).contains("HttpOnly");
        }

        @Test
        @DisplayName("Max-Age가 올바르게 설정된다")
        void should_set_maxAge_attribute() {
            // When
            tokenCookieWriter.addAccessTokenCookie(response, "token", 3600);

            // Then
            String cookie = response.getHeader("Set-Cookie");
            assertThat(cookie).contains("Max-Age=3600");
        }

        @Test
        @DisplayName("SameSite 속성이 설정된다")
        void should_set_sameSite_attribute() {
            // When
            tokenCookieWriter.addAccessTokenCookie(response, "token", 3600);

            // Then
            String cookie = response.getHeader("Set-Cookie");
            assertThat(cookie).contains("SameSite=Lax");
        }

        @Test
        @DisplayName("쿠키 이름이 access_token이다")
        void should_use_correct_cookie_name() {
            // When
            tokenCookieWriter.addAccessTokenCookie(response, "my-token", 3600);

            // Then
            String cookie = response.getHeader("Set-Cookie");
            assertThat(cookie).startsWith("access_token=my-token");
        }
    }

    @Nested
    @DisplayName("토큰 쿠키 삭제")
    class DeleteCookies {

        @Test
        @DisplayName("Max-Age=0으로 설정하여 쿠키를 삭제한다")
        void should_set_maxAge_zero() {
            // When
            tokenCookieWriter.deleteTokenCookies(response);

            // Then
            String cookies = response.getHeaders("Set-Cookie").toString();
            assertThat(cookies).contains("Max-Age=0");
        }

        @Test
        @DisplayName("access_token과 refresh_token 모두 삭제한다")
        void should_delete_both_tokens() {
            // When
            tokenCookieWriter.deleteTokenCookies(response);

            // Then
            assertThat(response.getHeaders("Set-Cookie"))
                .hasSize(2)
                .anyMatch(cookie -> cookie.contains("access_token="))
                .anyMatch(cookie -> cookie.contains("refresh_token="));
        }
    }

    @Nested
    @DisplayName("Secure 환경")
    class SecureEnvironment {

        @Test
        @DisplayName("Secure=true 설정 시 Secure 속성이 추가된다")
        void should_add_secure_attribute_when_enabled() {
            // Given
            SecurityProperties secureProperties = new SecurityProperties();
            secureProperties.getCookie().setSecure(true);
            secureProperties.getCookie().setSameSite("strict");

            TokenCookieWriter secureWriter = new TokenCookieWriter(secureProperties);
            MockHttpServletResponse secureResponse = new MockHttpServletResponse();

            // When
            secureWriter.addAccessTokenCookie(secureResponse, "token", 3600);

            // Then
            String cookie = secureResponse.getHeader("Set-Cookie");
            assertThat(cookie).contains("Secure");
        }
    }
}

7️⃣ SecurityConfig 통합 테스트

인가 규칙 동작 검증

package com.company.adapter.in.rest.auth.config;

import com.company.adapter.in.rest.auth.paths.ApiPaths;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
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.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * SecurityConfig 통합 테스트
 *
 * <p>실제 HTTP 요청으로 인가 규칙을 검증합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("SecurityConfig 통합 테스트")
class SecurityConfigIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Nested
    @DisplayName("Public Endpoints")
    class PublicEndpoints {

        @Test
        @DisplayName("로그인 엔드포인트는 인증 없이 접근 가능하다")
        void login_endpoint_is_public() {
            // When
            ResponseEntity<String> response = restTemplate.postForEntity(
                ApiPaths.Auth.LOGIN,
                new LoginRequest("test@test.com", "password"),
                String.class
            );

            // Then
            // 401이 아닌 다른 상태 (400, 200 등) → 인증 없이 접근됨
            assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED);
        }

        @Test
        @DisplayName("회원가입 엔드포인트는 인증 없이 접근 가능하다")
        void register_endpoint_is_public() {
            // When
            ResponseEntity<String> response = restTemplate.postForEntity(
                ApiPaths.Members.REGISTER,
                new RegisterRequest("test@test.com", "password", "testuser"),
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED);
        }

        @Test
        @DisplayName("Swagger UI는 인증 없이 접근 가능하다")
        void swagger_is_public() {
            // When
            ResponseEntity<String> response = restTemplate.getForEntity(
                "/swagger-ui/index.html",
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED);
        }

        @Test
        @DisplayName("Actuator health는 인증 없이 접근 가능하다")
        void actuator_health_is_public() {
            // When
            ResponseEntity<String> response = restTemplate.getForEntity(
                "/actuator/health",
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        }
    }

    @Nested
    @DisplayName("Protected Endpoints")
    class ProtectedEndpoints {

        @Test
        @DisplayName("주문 조회는 인증이 필요하다")
        void order_endpoint_requires_auth() {
            // When
            ResponseEntity<String> response = restTemplate.getForEntity(
                ApiPaths.Orders.BASE,
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        }

        @Test
        @DisplayName("회원 정보 조회는 인증이 필요하다")
        void member_me_endpoint_requires_auth() {
            // When
            ResponseEntity<String> response = restTemplate.getForEntity(
                ApiPaths.Members.ME,
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        }
    }

    @Nested
    @DisplayName("Admin Endpoints")
    class AdminEndpoints {

        @Test
        @DisplayName("Admin 엔드포인트는 인증 없이 접근 불가하다")
        void admin_endpoint_requires_auth() {
            // When
            ResponseEntity<String> response = restTemplate.getForEntity(
                ApiPaths.Admin.MEMBERS,
                String.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        }

        // 인증된 사용자 + ROLE_USER → 403 테스트는 별도 설정 필요
    }
}

8️⃣ Method Security 테스트

@PreAuthorize 동작 검증

package com.company.adapter.in.rest.order.controller;

import com.company.adapter.in.rest.auth.paths.ApiPaths;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * Method Security 통합 테스트
 *
 * <p>리소스 소유자 검증이 올바르게 동작하는지 검증합니다.
 *
 * @author development-team
 * @since 1.0.0
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("Method Security 테스트")
class MethodSecurityIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    @DisplayName("주문 소유자가 아닌 사용자의 주문 취소는 403을 반환한다")
    void non_owner_cancel_returns_403() {
        // Given
        String otherUserToken = getTokenForUser("other-user");
        Long orderId = 1L; // 다른 사용자의 주문

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(otherUserToken);
        HttpEntity<?> entity = new HttpEntity<>(headers);

        // When
        ResponseEntity<String> response = restTemplate.exchange(
            ApiPaths.Orders.BASE + "/" + orderId + "/cancel",
            HttpMethod.PATCH,
            entity,
            String.class
        );

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    @DisplayName("주문 소유자의 주문 취소는 성공한다")
    void owner_cancel_succeeds() {
        // Given
        String ownerToken = getTokenForUser("owner");
        Long orderId = 1L; // owner의 주문

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(ownerToken);
        HttpEntity<?> entity = new HttpEntity<>(headers);

        // When
        ResponseEntity<String> response = restTemplate.exchange(
            ApiPaths.Orders.BASE + "/" + orderId + "/cancel",
            HttpMethod.PATCH,
            entity,
            String.class
        );

        // Then
        assertThat(response.getStatusCode()).isIn(HttpStatus.OK, HttpStatus.NO_CONTENT);
    }

    // 테스트용 토큰 발급 헬퍼 메서드
    private String getTokenForUser(String userId) {
        // 테스트 환경에서 토큰 발급
        return "test-token-for-" + userId;
    }
}

9️⃣ 체크리스트

테스트 작성 체크리스트


📚 관련 가이드


작성자: Development Team 최종 수정일: 2025-11-13 버전: 1.0.0