From 3a02006d8cdccf03765433bbf7ae7c3058d4fd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:08:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test:=20JwtService/UserIdResolver=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20dev=20CI=EC=97=90=EC=84=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtServiceTest: 토큰 발급/검증/만료/클레임 추출 검증 - UserIdResolverTest: 토큰 누락/만료/무효, 방문자 모드, userId 파싱 및 MDC 반영 검증 - dev-ci.yml: `-x test` 제거 — 지금까지 테스트가 아예 실행 안 되고 있었음 --- .github/workflows/dev-ci.yml | 2 +- .../resolver/userId/UserIdResolverTest.java | 131 ++++++++++++++++++ .../server/config/jwt/JwtServiceTest.java | 57 ++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java create mode 100644 src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 64b96ee..3a7d81f 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -49,4 +49,4 @@ jobs: run: chmod +x gradlew - name: Build with Gradle # 실제 application build - run: ./gradlew build -PactiveProfiles=local -x test + run: ./gradlew build -PactiveProfiles=local 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); + } +} From 3eaa1940f64e04fdf0fc585ec5dcf7ed572ce62b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:12:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20dev=20CI=EA=B0=80=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20DB=20=EB=8C=80=EC=8B=A0=20=EC=9E=84=EC=8B=9C=20post?= =?UTF-8?q?gres/redis=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -x test 제거 직후 CI에서 ServerApplicationTests가 SocketTimeoutException으로 실패함 — GitHub Actions 러너에서 실제 dev DB/Redis로 네트워크가 안 닿는 게 원인. CI에 postgres/redis 서비스 컨테이너를 띄우고 SPRING_DATASOURCE_URL/ SPRING_DATA_REDIS_HOST 환경변수로 그쪽을 바라보게 해서 해결. --- .github/workflows/dev-ci.yml | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 3a7d81f..d3edf41 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,17 @@ 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 + env: + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/runnect + SPRING_DATA_REDIS_HOST: localhost From 4c83df7e4bbb60e19897e1854134b784b8f77ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:14:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20CI=20=EC=9E=84=EC=8B=9C=20DB=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=A0=95=EB=B3=B4(username/password)?= =?UTF-8?q?=EB=8F=84=20=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index d3edf41..adc6976 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -78,4 +78,6 @@ jobs: 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