diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..faf9300 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/issueDive/config/SecurityConfig.java b/src/main/java/com/issueDive/config/SecurityConfig.java index ce1970a..9f0ed49 100644 --- a/src/main/java/com/issueDive/config/SecurityConfig.java +++ b/src/main/java/com/issueDive/config/SecurityConfig.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -49,18 +50,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 - // 9월1일 변경 - 세션 관리 정책 (JWT는 stateless) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize - // 모든 요청(/**)을 허용 (임시로) - //.anyRequest().permitAll() // 개발 단계에서는 전체 허용 .requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용 + .requestMatchers("/auth/**", "/login").permitAll() // 1. 로그인/인증 관련 경로는 모두 허용 + .requestMatchers(HttpMethod.GET, "/issues", "/issues/**").permitAll() // 2. 이슈 조회(GET)는 모두 허용 + .requestMatchers(HttpMethod.GET, "/labels", "/labels/**").permitAll() // 3. 라벨 조회(GET)도 모두 허용 .anyRequest().authenticated() // 나머지는 인증 필요 ) - .formLogin(formLogin -> formLogin.disable()) // 폼 로그인 비활성화 (필요 시) - .logout(logout -> logout.disable()); - http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);// 로그아웃 비활성화 (필요 시) + .formLogin(formLogin -> formLogin.disable()) // 폼 로그인 비활성화 (서버 사이드 렌더링 사용X) + .logout(logout -> logout.disable()); // 로그아웃 비활성화 (서버에 로그인 상태 저장X: Stateless) + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/issueDive/service/IssueService.java b/src/main/java/com/issueDive/service/IssueService.java index 6fd1a80..412d4ad 100644 --- a/src/main/java/com/issueDive/service/IssueService.java +++ b/src/main/java/com/issueDive/service/IssueService.java @@ -73,7 +73,13 @@ public Page getFilteredIssues(IssueFilterRequest filter) { if (filter.labelIds()!=null && !filter.labelIds().isEmpty()) builder.and(qIssue.labels.any().id.in(filter.labelIds())); if (filter.query() != null && !filter.query().isBlank()) { - builder.and(qIssue.title.containsIgnoreCase(filter.query())); + String searchQuery = filter.query(); + builder.and( + qIssue.title.containsIgnoreCase(searchQuery) // 1. 제목에서 검색 + .or(qIssue.author.username.containsIgnoreCase(searchQuery)) // 2. 작성자 이름으로 검색 + .or(qIssue.assignee.username.containsIgnoreCase(searchQuery)) // 3. 담당자 이름으로 검색 + .or(qIssue.labels.any().name.containsIgnoreCase(searchQuery)) // 4. 라벨 이름 중 하나라도 일치하면 검색 + ); } // 페이징 객체 diff --git a/src/test/java/com/issueDive/config/SecurityConfigTest.java b/src/test/java/com/issueDive/config/SecurityConfigTest.java index 6607b65..9b9de2f 100644 --- a/src/test/java/com/issueDive/config/SecurityConfigTest.java +++ b/src/test/java/com/issueDive/config/SecurityConfigTest.java @@ -2,6 +2,7 @@ import com.issueDive.security.CustomUserDetailsService; import com.issueDive.security.JwtAuthenticationFilter; +import com.issueDive.service.IssueService; import com.issueDive.util.JwtUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,6 +21,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -36,6 +38,8 @@ public class SecurityConfigTest { @MockitoBean private CustomUserDetailsService customUserDetailsService; + @MockitoBean + private IssueService issueService; private static final String VALID_TOKEN = "valid.jwt.token"; private static final String USER_EMAIL = "test@example.com"; @@ -81,26 +85,43 @@ void publicUrl_Swagger_AllowedWithoutAuth() throws Exception { } @Test - @DisplayName("보호된 URL 인증 없이 접근 차단") - void protectedUrl_WithoutAuth_Blocked() throws Exception { + @DisplayName("permitAll()된 GET /issues는 인증 없이 접근 가능") + void publicGetIssues_WithoutAuth_Allowed() throws Exception { + // GET /issues는 이제 permitAll이므로 403 Forbidden이 아닌 200 OK를 기대해야 합니다. mockMvc.perform(get("/issues")) .andDo(print()) - .andExpect(status().isForbidden()); + .andExpect(status().isOk()); + } + + @Test + @DisplayName("보호된 URL(POST /issues)은 인증 없이 접근 차단") + void protectedPostUrl_WithoutAuth_Blocked() throws Exception { + // 테스트 대상을 GET이 아닌 POST로 변경하여 보호 여부를 확인합니다. + // POST, PATCH, DELETE 등은 여전히 인증이 필요합니다. + mockMvc.perform(post("/issues") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Test\"}")) + .andDo(print()) + .andExpect(status().isForbidden()); // JWT 필터가 없으므로 403 Forbidden } @Test - @DisplayName("유효한 JWT 토큰으로 보호된 URL 접근 허용") + @DisplayName("유효한 JWT 토큰으로 보호된 URL(POST /issues) 접근 허용") void protectedUrl_WithValidToken_Allowed() throws Exception { // given given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - // when & then - mockMvc.perform(get("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN)) + String validIssueJson = "{\"title\":\"Test Issue\",\"description\":\"Test Description\"}"; + + // 테스트 대상을 GET이 아닌 POST로 변경하여 토큰 인증을 테스트합니다. + mockMvc.perform(post("/issues") + .header("Authorization", "Bearer " + VALID_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(validIssueJson)) .andDo(print()) - .andExpect(status().isOk()); // 또는 404 (컨트롤러 없음) + .andExpect(status().isCreated()); // 컨트롤러 로직에 따라 201 Created 또는 다른 성공 코드를 기대 } @Test @@ -111,11 +132,13 @@ void protectedUrl_WithInvalidToken_Blocked() throws Exception { given(jwtUtil.getUserEmailFromToken(invalidToken)).willReturn(USER_EMAIL); given(jwtUtil.validateToken(invalidToken, USER_EMAIL)).willReturn(false); - // when & then - mockMvc.perform(get("/issues") - .header("Authorization", "Bearer " + invalidToken)) + // 테스트 대상을 GET이 아닌 POST로 변경 + mockMvc.perform(post("/issues") + .header("Authorization", "Bearer " + invalidToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Test\"}")) .andDo(print()) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()); // JwtAuthenticationFilter에서 401 Unauthorized 반환 } @Test @@ -156,14 +179,17 @@ void sessionPolicy_Stateless() throws Exception { // 첫 번째 요청 mockMvc.perform(post("/auth/login") .contentType(MediaType.APPLICATION_JSON) - .content("{}")) + .content("{\"email\":\"test@test.com\", \"password\":\"password\"}")) .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 Bad Request + .andExpect(status().isOk()); - // 두 번째 요청 - 세션이 유지되지 않아야 함 - mockMvc.perform(get("/issues")) + // 두 번째 요청도 GET /issues로 보내면 permitAll 이므로 성공합니다. + // 대신 보호된 경로인 POST /issues로 보내서 세션이 유지되지 않음을 확인합니다. + mockMvc.perform(post("/issues") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Test\"}")) .andDo(print()) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()); // 세션이 없으므로 인증 실패 } @Test @@ -199,14 +225,20 @@ void httpMethod_POST_Allowed() throws Exception { @Test @DisplayName("HTTP 메서드별 접근 제어 - DELETE") void httpMethod_DELETE_Allowed() throws Exception { + // given: 인증 관련 설정 (기존과 동일) given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); + // given: IssueService의 deleteIssue 메소드가 호출될 때 아무것도 하지 않도록 설정 (성공 시나리오) + doNothing().when(issueService).deleteIssue(1L); + + // when & then mockMvc.perform(delete("/issues/1") .header("Authorization", "Bearer " + VALID_TOKEN)) .andDo(print()) - .andExpect(status().is4xxClientError()); // 404 (리소스 없음) + // then: 4xx 에러가 아닌, 성공 상태 코드인 200 OK를 기대하도록 변경 + .andExpect(status().isOk()); } @Test diff --git a/src/test/java/com/issueDive/controller/AuthControllerTest.java b/src/test/java/com/issueDive/controller/AuthControllerTest.java index 4f0cedf..8d14285 100644 --- a/src/test/java/com/issueDive/controller/AuthControllerTest.java +++ b/src/test/java/com/issueDive/controller/AuthControllerTest.java @@ -24,6 +24,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.mockito.MockitoAnnotations.openMocks; @@ -57,15 +58,6 @@ public class AuthControllerTest { @MockitoBean private CustomUserDetailsService customUserDetailsService; - @MockitoBean - private JwtUtil jwtUtil; - - @MockitoBean // 9월 2일 변경: AuthenticationManager mock 추가 - private AuthenticationManager authenticationManager; - - @MockitoBean // 9월 2일 수정: CustomUserDetailsService Mock 추가 (빈 찾을 수 없음 에러 해결) - private CustomUserDetailsService customUserDetailsService; - @Test @DisplayName("[SUCCESS] POST /auth/signup - 회원가입 성공") void signUp_success() throws Exception { @@ -228,4 +220,3 @@ void logout_success() throws Exception { } } -}