diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 64b96ee..adc6976 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -19,6 +19,32 @@ jobs: runs-on: ubuntu-latest + # 테스트(contextLoads 등)는 실제 dev DB/Redis가 아니라 CI 안에서 띄우는 + # 임시 컨테이너를 바라보게 한다 (원격 dev 환경은 GitHub Actions 러너에서 네트워크가 닿지 않음). + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: runnect + POSTGRES_USER: runnect + POSTGRES_PASSWORD: runnect_local_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v3 - name: Set up JDK 11 @@ -39,14 +65,19 @@ jobs: # GitHub-Actions 에서 설정한 값을 application.yml 파일에 쓰기 echo "${{ secrets.RUNNECT_DEV_APPLICATION }}" >> ./application.yml - + # application.yml 파일 확인 cat ./application.yml shell: bash - + # 이 워크플로우는 gradle build - name: Grant execute permission for gradlew run: chmod +x gradlew - + - name: Build with Gradle # 실제 application build - run: ./gradlew build -PactiveProfiles=local -x test + run: ./gradlew build -PactiveProfiles=local + env: + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/runnect + SPRING_DATASOURCE_USERNAME: runnect + SPRING_DATASOURCE_PASSWORD: runnect_local_password + SPRING_DATA_REDIS_HOST: localhost diff --git a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java new file mode 100644 index 0000000..acf2471 --- /dev/null +++ b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java @@ -0,0 +1,131 @@ +package org.runnect.server.common.resolver.userId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.user.exception.authException.InvalidAccessTokenException; +import org.runnect.server.user.exception.authException.NullAccessTokenException; +import org.runnect.server.user.exception.authException.TimeExpiredAccessTokenException; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.slf4j.MDC; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.NativeWebRequest; + +class UserIdResolverTest { + + private static final Long VISITOR_ID = 0L; + + private JwtService jwtService; + private UserIdResolver userIdResolver; + private MethodParameter methodParameter; + + @BeforeEach + void setUp() { + jwtService = mock(JwtService.class); + userIdResolver = new UserIdResolver(jwtService); + ReflectionTestUtils.setField(userIdResolver, "VISITOR_ID", VISITOR_ID); + ReflectionTestUtils.invokeMethod(userIdResolver, "setVISITOR_POSSIBLE_URLS", "/api/public-course"); + methodParameter = mock(MethodParameter.class); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private NativeWebRequest webRequestWith(String accessToken, String refreshToken, String method, String uri) { + HttpServletRequest servletRequest = mock(HttpServletRequest.class); + when(servletRequest.getHeader("accessToken")).thenReturn(accessToken); + when(servletRequest.getHeader("refreshToken")).thenReturn(refreshToken); + when(servletRequest.getMethod()).thenReturn(method); + when(servletRequest.getRequestURI()).thenReturn(uri); + + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getNativeRequest()).thenReturn(servletRequest); + return webRequest; + } + + @Test + void accessToken이_없으면_예외를_던진다() { + NativeWebRequest webRequest = webRequestWith(null, "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NullAccessTokenException.class); + } + + @Test + void refreshToken이_없으면_예외를_던진다() { + NativeWebRequest webRequest = webRequestWith("access", null, "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NullAccessTokenException.class); + } + + @Test + void 방문자_모드_허용_URL이면_VISITOR_ID를_반환하고_MDC에_채운다() { + NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123"); + + Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); + + assertThat(result).isEqualTo(VISITOR_ID); + assertThat(MDC.get("userId")).isEqualTo(String.valueOf(VISITOR_ID)); + } + + @Test + void 만료된_토큰이면_예외를_던진다() { + when(jwtService.verifyToken("expired")).thenReturn(TokenStatus.TOKEN_EXPIRED); + NativeWebRequest webRequest = webRequestWith("expired", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(TimeExpiredAccessTokenException.class); + } + + @Test + void 유효하지_않은_토큰이면_예외를_던진다() { + when(jwtService.verifyToken("invalid")).thenReturn(TokenStatus.TOKEN_INVALID); + NativeWebRequest webRequest = webRequestWith("invalid", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(InvalidAccessTokenException.class); + } + + @Test + void 유효한_토큰이면_userId를_반환하고_MDC에_채운다() { + when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("valid")).thenReturn("42"); + NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); + + Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); + + assertThat(result).isEqualTo(42L); + assertThat(MDC.get("userId")).isEqualTo("42"); + } + + @Test + void 토큰의_userId_클레임이_숫자가_아니면_예외를_던진다() { + when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("valid")).thenReturn("not-a-number"); + NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + void UserId_애노테이션과_Long_타입일_때만_지원한다() { + when(methodParameter.hasParameterAnnotation(UserId.class)).thenReturn(true); + when(methodParameter.getParameterType()).thenReturn((Class) Long.class); + + assertThat(userIdResolver.supportsParameter(methodParameter)).isTrue(); + } +} diff --git a/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java b/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java new file mode 100644 index 0000000..5b84596 --- /dev/null +++ b/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java @@ -0,0 +1,57 @@ +package org.runnect.server.config.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.redis.RedisService; +import org.springframework.test.util.ReflectionTestUtils; + +class JwtServiceTest { + + private JwtService jwtService; + + @BeforeEach + void setUp() { + RedisService redisService = mock(RedisService.class); + jwtService = new JwtService(redisService); + ReflectionTestUtils.setField(jwtService, "jwtSecret", "test-secret-key-for-jwt-service-unit-test-only"); + ReflectionTestUtils.invokeMethod(jwtService, "init"); + } + + @Test + void 발급한_액세스_토큰은_검증에_성공한다() { + String accessToken = jwtService.issuedAccessToken(1L); + + long status = jwtService.verifyToken(accessToken); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_VALID); + } + + @Test + void 발급한_토큰에서_userId_클레임을_그대로_추출한다() { + String accessToken = jwtService.issuedAccessToken(42L); + + String userId = jwtService.getJwtContents(accessToken); + + assertThat(userId).isEqualTo("42"); + } + + @Test + void 형식이_깨진_토큰은_INVALID로_판정한다() { + long status = jwtService.verifyToken("not-a-real-jwt"); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_INVALID); + } + + @Test + void 이미_만료된_토큰은_EXPIRED로_판정한다() { + String expiredToken = jwtService.issuedToken("access_token", -1000L, "1"); + + long status = jwtService.verifyToken(expiredToken); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_EXPIRED); + } +}