From a7cb2c538df0a396aa98ecc41b5e91878eb94075 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:41:55 +0000 Subject: [PATCH] Upgrade to Spring Boot 2.7.18, migrate security config, add JaCoCo coverage --- .github/workflows/build.yml | 25 ++++ build.gradle | 41 ++++++- gradle.properties | 1 + .../api/security/WebSecurityConfig.java | 10 +- .../api/security/WebSecurityConfigTest.java | 110 ++++++++++++++++++ .../service/DefaultJwtServiceTest.java | 3 +- 6 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 gradle.properties create mode 100644 src/test/java/io/spring/api/security/WebSecurityConfigTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..a9df65636 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + push: + branches: [master] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' + - uses: gradle/actions/setup-gradle@v3 + - name: Build, test and verify coverage + run: ./gradlew build + - name: Upload JaCoCo report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: build/reports/jacoco/test/ diff --git a/build.gradle b/build.gradle index da384dc69..e887cc19b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ plugins { - id 'org.springframework.boot' version '2.6.3' + id 'org.springframework.boot' version '2.7.18' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' + id 'jacoco' id "com.netflix.dgs.codegen" version "5.0.6" id "com.diffplug.spotless" version "6.2.1" } @@ -58,6 +59,44 @@ dependencies { tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +def jacocoExcludes = ['io/spring/graphql/types/**', 'io/spring/graphql/DgsConstants*'] + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } +} + +jacocoTestCoverageVerification { + dependsOn test + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: jacocoExcludes) + })) + } + violationRules { + rule { + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.50 + } + } + } +} + +tasks.named('check') { + dependsOn jacocoTestCoverageVerification } tasks.named('clean') { diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..ed7cdf7b0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/src/main/java/io/spring/api/security/WebSecurityConfig.java b/src/main/java/io/spring/api/security/WebSecurityConfig.java index 3786959ef..9b8e1449f 100644 --- a/src/main/java/io/spring/api/security/WebSecurityConfig.java +++ b/src/main/java/io/spring/api/security/WebSecurityConfig.java @@ -8,10 +8,10 @@ import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -20,7 +20,7 @@ @Configuration @EnableWebSecurity -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { +public class WebSecurityConfig { @Bean public JwtTokenFilter jwtTokenFilter() { @@ -32,8 +32,8 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - @Override - protected void configure(HttpSecurity http) throws Exception { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf() .disable() @@ -62,6 +62,8 @@ protected void configure(HttpSecurity http) throws Exception { .authenticated(); http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); } @Bean diff --git a/src/test/java/io/spring/api/security/WebSecurityConfigTest.java b/src/test/java/io/spring/api/security/WebSecurityConfigTest.java new file mode 100644 index 000000000..1abd965fd --- /dev/null +++ b/src/test/java/io/spring/api/security/WebSecurityConfigTest.java @@ -0,0 +1,110 @@ +package io.spring.api.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.spring.api.TagsApi; +import io.spring.application.TagsQueryService; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.readservice.UserReadService; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TagsApi.class) +@Import(WebSecurityConfig.class) +public class WebSecurityConfigTest { + + @Autowired private MockMvc mvc; + + @MockBean private UserRepository userRepository; + @MockBean private UserReadService userReadService; + @MockBean private JwtService jwtService; + @MockBean private TagsQueryService tagsQueryService; + + private User user; + + @BeforeEach + public void setUp() { + user = new User("john@jacob.com", "johnjacob", "123", "", ""); + Mockito.when(userRepository.findById(ArgumentMatchers.eq(user.getId()))) + .thenReturn(Optional.of(user)); + Mockito.when(jwtService.getSubFromToken(ArgumentMatchers.eq("valid-token"))) + .thenReturn(Optional.of(user.getId())); + Mockito.when(jwtService.getSubFromToken(ArgumentMatchers.eq("invalid-token"))) + .thenReturn(Optional.empty()); + } + + @Test + public void should_permit_options_requests_without_token() throws Exception { + mvc.perform(options("/articles")) + .andExpect( + result -> + org.junit.jupiter.api.Assertions.assertNotEquals( + 401, result.getResponse().getStatus())); + } + + @Test + public void should_permit_graphql_endpoints_without_token() throws Exception { + mvc.perform(get("/graphiql")) + .andExpect( + result -> + org.junit.jupiter.api.Assertions.assertNotEquals( + 401, result.getResponse().getStatus())); + mvc.perform(post("/graphql")) + .andExpect( + result -> + org.junit.jupiter.api.Assertions.assertNotEquals( + 401, result.getResponse().getStatus())); + } + + @Test + public void should_permit_user_registration_and_login_without_token() throws Exception { + mvc.perform(post("/users")).andExpect(status().isNotFound()); + mvc.perform(post("/users/login")).andExpect(status().isNotFound()); + } + + @Test + public void should_permit_public_read_endpoints_without_token() throws Exception { + mvc.perform(get("/articles")).andExpect(status().isNotFound()); + mvc.perform(get("/articles/some-slug")).andExpect(status().isNotFound()); + mvc.perform(get("/profiles/johnjacob")).andExpect(status().isNotFound()); + mvc.perform(get("/tags")).andExpect(status().isOk()); + } + + @Test + public void should_reject_articles_feed_without_token() throws Exception { + mvc.perform(get("/articles/feed")).andExpect(status().isUnauthorized()); + } + + @Test + public void should_reject_protected_endpoints_without_token() throws Exception { + mvc.perform(get("/user")).andExpect(status().isUnauthorized()); + mvc.perform(post("/articles")).andExpect(status().isUnauthorized()); + } + + @Test + public void should_reject_protected_endpoints_with_invalid_token() throws Exception { + mvc.perform(get("/user").header("Authorization", "Token invalid-token")) + .andExpect(status().isUnauthorized()); + } + + @Test + public void should_allow_protected_endpoints_with_valid_token() throws Exception { + mvc.perform(get("/articles/feed").header("Authorization", "Token valid-token")) + .andExpect(status().isNotFound()); + mvc.perform(get("/user").header("Authorization", "Token valid-token")) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java b/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java index 12929118a..b2261705e 100644 --- a/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java +++ b/src/test/java/io/spring/infrastructure/service/DefaultJwtServiceTest.java @@ -13,7 +13,8 @@ public class DefaultJwtServiceTest { @BeforeEach public void setUp() { - jwtService = new DefaultJwtService("123123123123123123123123123123123123123123123123123123123123", 3600); + jwtService = + new DefaultJwtService("123123123123123123123123123123123123123123123123123123123123", 3600); } @Test