diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index c740de4..ef4eb04 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -17,6 +17,34 @@ on: jobs: deploy: runs-on: ubuntu-latest + + # clean build 가 테스트(@SpringBootTest 컨텍스트 로드 포함)를 돌리므로 CI와 동일한 DB/Redis 필요 + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: ci_root_pw + MARIADB_DATABASE: sb_db + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=15 + redis: + image: redis:7 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DB_URL: jdbc:mariadb://mariadb:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + DB_USERNAME: root + DB_PASSWORD: ci_root_pw + REDIS_HOST: redis + REDIS_PORT: '6379' + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java new file mode 100644 index 0000000..44993e9 --- /dev/null +++ b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java @@ -0,0 +1,135 @@ +package com.sb.web.auth.controller; + +import com.sb.web.admin.service.AppSettingService; +import com.sb.web.auth.dto.MemberResponse; +import com.sb.web.auth.dto.SessionUser; +import com.sb.web.auth.service.AuthService; +import com.sb.web.auth.web.AdminInterceptor; +import com.sb.web.auth.web.AuthInterceptor; +import com.sb.web.common.config.WebConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 인증 컨트롤러 웹 계층 통합테스트 (@WebMvcTest — DB/Redis 불필요). + * 실제 인터셉터(AuthInterceptor)·경로 매핑(WebConfig)을 로드해 와이어링을 검증한다. + * 단위테스트가 못 잡은 "보호 경로 누락 → 401 대신 500" 회귀를 이 계층이 잡는다. + */ +@WebMvcTest(AuthController.class) +@Import({WebConfig.class, AuthInterceptor.class, AdminInterceptor.class}) +class AuthControllerWebMvcTest { + + @Autowired MockMvc mvc; + @MockitoBean AuthService authService; + @MockitoBean AppSettingService appSettingService; + + /** + * @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음). + * 매퍼는 서비스 모킹으로 실행되지 않으므로, DB 연결 없는 경량 팩토리로 빈 생성만 통과시킨다. + */ + @TestConfiguration + static class MyBatisStubConfig { + @org.springframework.context.annotation.Bean + org.apache.ibatis.session.SqlSessionFactory sqlSessionFactory() { + org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); + cfg.setEnvironment(new org.apache.ibatis.mapping.Environment( + "test", + new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), + new org.apache.ibatis.datasource.unpooled.UnpooledDataSource())); + return new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(cfg); + } + + @org.springframework.context.annotation.Bean + org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate(org.apache.ibatis.session.SqlSessionFactory f) { + return new org.mybatis.spring.SqlSessionTemplate(f); + } + } + + private SessionUser loggedIn() { + return SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").provider("LOCAL").build(); + } + + // ===== 보호 경로: 토큰 없으면 401 (500 아님) — 인터셉터 등록 회귀 가드 ===== + + @Test + @DisplayName("verify-password: 토큰 없으면 401") + void verifyPassword_noToken_401() throws Exception { + mvc.perform(post("/api/auth/verify-password") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"pw\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("profile: 토큰 없으면 401") + void profile_noToken_401() throws Exception { + mvc.perform(put("/api/auth/profile") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"새이름\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("me: 토큰 없으면 401") + void me_noToken_401() throws Exception { + mvc.perform(get("/api/auth/me")) + .andExpect(status().isUnauthorized()); + } + + // ===== 보호 경로: 유효 토큰이면 컨트롤러까지 도달(CURRENT_MEMBER 주입) ===== + + @Test + @DisplayName("verify-password: 유효 토큰이면 컨트롤러 도달 → 204") + void verifyPassword_validToken_204() throws Exception { + when(authService.getSession("valid")).thenReturn(loggedIn()); + + mvc.perform(post("/api/auth/verify-password") + .header("Authorization", "Bearer valid") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"pw\"}")) + .andExpect(status().isNoContent()); + + verify(authService).verifyPassword(1L, "pw"); + } + + @Test + @DisplayName("profile: 유효 토큰이면 변경 → 200") + void profile_validToken_200() throws Exception { + when(authService.getSession("valid")).thenReturn(loggedIn()); + when(authService.updateProfile(eq(1L), eq("valid"), any())) + .thenReturn(MemberResponse.builder().id(1L).loginId("u1").name("새이름").email("a@b.com").build()); + + mvc.perform(put("/api/auth/profile") + .header("Authorization", "Bearer valid") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"새이름\",\"email\":\"a@b.com\"}")) + .andExpect(status().isOk()); + } + + // ===== 공개 경로: 토큰 없이도 접근 가능(인터셉터 미적용) ===== + + @Test + @DisplayName("signup-enabled: 공개 — 토큰 없이 200") + void signupEnabled_public_200() throws Exception { + when(appSettingService.isSignupEnabled()).thenReturn(true); + + mvc.perform(get("/api/auth/signup-enabled")) + .andExpect(status().isOk()); + } +}