diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 82c1314..de73612 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 5335cfb..51fd592 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 716ff19..3743127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,24 +13,27 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} - - name: Set up JDK 21 - uses: actions/setup-java@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v5 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Grant execute permissions for gradlew run: chmod +x gradlew - - name: Build with Gradle - uses: gradle/gradle-build-action@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 env: SPRING_PROFILES_ACTIVE: integration-test-ci - with: - arguments: clean build - build-root-directory: . + + - name: Run build in a root + working-directory: . + run: ./gradlew clean build + - name: Add coverage to PR id: jacoco uses: madrapps/jacoco-report@v1.7.2 @@ -40,7 +43,7 @@ jobs: min-coverage-overall: 60 min-coverage-changed-files: 50 - name: Generate JaCoCo Badge - uses: cicirello/jacoco-badge-generator@v2.11.0 + uses: cicirello/jacoco-badge-generator@v2 with: generate-branches-badge: true jacoco-csv-file: build/reports/jacoco/test/jacocoTestReport.csv diff --git a/Dockerfile b/Dockerfile index 930af20..643fb37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ #### Stage 1: Build the application -FROM amazoncorretto:21 AS BUILD_IMAGE +FROM amazoncorretto:25 AS BUILD_IMAGE # Set the current working directory inside the image WORKDIR /app @@ -39,7 +39,7 @@ RUN mkdir -p dependency \ && (cd dependency || return; jar -xf ../libs/*.jar) #### Stage 2: A minimal docker image with command to run the app -FROM --platform=linux/amd64 amazoncorretto:21 AS RUNNER +FROM --platform=linux/amd64 amazoncorretto:25 AS RUNNER # Set the current working directory inside the image WORKDIR /app diff --git a/README.md b/README.md index 9df985d..b0be570 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A highly opinionated and complete starter for Spring Boot production ready proje ## Built With -* [Spring Boot 3.5.5](https://projects.spring.io/spring-boot/) - Inversion of Control Framework +* [Spring Boot 3.5.7](https://projects.spring.io/spring-boot/) - Inversion of Control Framework * [Bootstrap 5](https://getbootstrap.com/docs/5.0/getting-started/introduction/) - HTML, CSS, and JavaScript framework * [Thymeleaf](https://www.thymeleaf.org/) - Modern server-side Java template engine diff --git a/build.gradle b/build.gradle index 94adfb1..03284bc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'org.springframework.boot' version '3.5.5' + id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.7' - id 'org.owasp.dependencycheck' version '12.1.6' + id 'org.owasp.dependencycheck' version '12.1.8' id 'com.diffplug.spotless' version '8.0.0' id 'eclipse' id 'jacoco' @@ -14,7 +14,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } // You can uncomment this to include javadoc jar generation // withJavadocJar() @@ -39,7 +39,13 @@ ext { set('springDocVersion', '2.8.13') set('javadocScribeVersion', '0.15.0') set('mapstructVersion', '1.6.3') - set('awsSdkS3Version', '2.31.45') + set('awsSdkS3Version', '2.38.2') + set('jsoupVersion', '1.21.2') + set('commonsIoVersion', '2.21.0') + set('datafakerVersion', '2.5.3') + set('gsonVersion', '2.13.2') + set('commonsValidatorVersion', '1.10.0') + set('springDataJpaDataTablesVersion', '7.1.0') } dependencies { @@ -63,15 +69,15 @@ dependencies { implementation 'org.ehcache:ehcache-transactions:3.10.8' // Utilities - implementation 'org.jsoup:jsoup:1.21.2' + implementation "org.jsoup:jsoup:${jsoupVersion}" implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" - implementation 'commons-io:commons-io:2.20.0' - implementation 'net.datafaker:datafaker:2.5.1' - implementation 'com.google.code.gson:gson:2.13.2' - implementation 'commons-validator:commons-validator:1.10.0' - implementation 'com.github.darrachequesne:spring-data-jpa-datatables:7.1.0' + implementation "commons-io:commons-io:${commonsIoVersion}" + implementation "net.datafaker:datafaker:${datafakerVersion}" + implementation "com.google.code.gson:gson:${gsonVersion}" + implementation "commons-validator:commons-validator:${commonsValidatorVersion}" + implementation "com.github.darrachequesne:spring-data-jpa-datatables:${springDataJpaDataTablesVersion}" // Documentation and API Tools implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springDocVersion}" @@ -110,7 +116,7 @@ testing { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' implementation 'io.findify:s3mock_2.13:0.2.6' - implementation 'com.icegreen:greenmail-junit5:2.1.3' + implementation 'com.icegreen:greenmail-junit5:2.1.7' implementation 'com.jparams:to-string-verifier:1.4.8' implementation 'nl.jqno.equalsverifier:equalsverifier:4.0' implementation 'org.springframework.security:spring-security-test' diff --git a/docker-compose.yml b/docker-compose.yml index 506c572..63e0c36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: # Postgres database diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3afee19..f598efd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/integrationTest/java/com/developersboard/TestConfig.java b/src/integrationTest/java/com/developersboard/TestConfig.java index 579a471..b512a59 100644 --- a/src/integrationTest/java/com/developersboard/TestConfig.java +++ b/src/integrationTest/java/com/developersboard/TestConfig.java @@ -14,6 +14,9 @@ import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import java.net.URI; /** * This class provides every bean, and other configurations needed to be used in the testing phase. @@ -79,6 +82,24 @@ public S3Client s3Client(AwsProperties props) { AwsBasicCredentials.create(props.getAccessKeyId(), props.getSecretAccessKey()); return S3Client.builder() + .endpointOverride(URI.create("http://localhost:8001")) // match your S3Mock port + .region(Region.of(props.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(true) // must be true for S3Mock + .build() + ) + .build(); + } + + @Bean + public S3Presigner s3Presigner(AwsProperties props) { + AwsBasicCredentials credentials = + AwsBasicCredentials.create(props.getAccessKeyId(), props.getSecretAccessKey()); + + return S3Presigner.builder() + .endpointOverride(URI.create("http://localhost:8001")) // S3Mock .region(Region.of(props.getRegion())) .credentialsProvider(StaticCredentialsProvider.create(credentials)) .build(); diff --git a/src/integrationTest/java/com/developersboard/backend/service/storage/AmazonS3ServiceIntegrationTest.java b/src/integrationTest/java/com/developersboard/backend/service/storage/AmazonS3ServiceIntegrationTest.java index 095624c..670cbc0 100644 --- a/src/integrationTest/java/com/developersboard/backend/service/storage/AmazonS3ServiceIntegrationTest.java +++ b/src/integrationTest/java/com/developersboard/backend/service/storage/AmazonS3ServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.developersboard.backend.service.storage; -import com.amazonaws.services.s3.model.AmazonS3Exception; import com.developersboard.IntegrationTestUtils; import com.developersboard.constant.StorageConstants; import com.developersboard.exception.InvalidFileFormatException; @@ -10,40 +9,53 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; +import java.net.URI; import javax.imageio.ImageIO; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; import org.mockito.Mockito; - -@Disabled("S3 Mocking needs to be refactored to use v2 AWS. S3Mock won't support v2") +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +/** + * This annotation tells JUnit to reuse the same test instance for the entire test class. + * Then @BeforeAll does NOT need to be static anymore. + *
+ * To use awsProperties inside @BeforeAll, + * you need to avoid the static requirement that JUnit puts on @BeforeAll + * with @TestInstance(TestInstance.Lifecycle.PER_CLASS) + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class AmazonS3ServiceIntegrationTest extends IntegrationTestUtils { /* S3Mock.create(8001, "/tmp/s3"); */ - private transient S3Mock api; - - @BeforeEach - void setUp(TestInfo testInfo) { - multipartFile = getMultipartFile(testInfo.getDisplayName(), false); + private S3Mock api; + @BeforeAll + void beforeAll() { api = new S3Mock.Builder() .withPort(Integer.parseInt(awsProperties.getServicePort())) .withInMemoryBackend() .build(); + api.start(); } - @AfterEach - void tearDown() { - api.shutdown(); // kills the underlying actor system. Use api.stop() to just unbind the port. + @AfterAll + void afterAll() { + api.stop(); + } + + @BeforeEach + void setUp(TestInfo testInfo) { + multipartFile = getMultipartFile(testInfo.getDisplayName(), false); } @Test @@ -114,14 +126,16 @@ void generatedPreSignedUrl(TestInfo testInfo) throws Exception { var imageUrl = amazonS3Service.storeProfileImage(multipartFile, testInfo.getDisplayName()); var preSignedUrl = amazonS3Service.generatePreSignedUrl(imageUrl); - var expectedUrl = - String.format( - "%s/%s/profileImages/%s/profileImage.png", - awsProperties.getServiceEndpoint(), - awsProperties.getS3BucketName(), - URLEncoder.encode(testInfo.getDisplayName(), StandardCharsets.UTF_8)); + URI uri = URI.create(preSignedUrl); + + var expectedPath = String.format( + "/profileImages/%s/profileImage.png", + testInfo.getDisplayName() + ); - Assertions.assertEquals(expectedUrl, preSignedUrl); + Assertions.assertEquals(expectedPath, uri.getPath()); + Assertions.assertTrue(preSignedUrl.contains("X-Amz-Expires")); + Assertions.assertTrue(preSignedUrl.contains("X-Amz-Signature")); } @Test @@ -135,7 +149,7 @@ void deleteProfileImage(TestInfo testInfo) throws IOException, InterruptedExcept amazonS3Service.delete(imageUrl); // We will get a AmazonS3Exception 404 error if the key doesn't exist. - Assertions.assertThrows(AmazonS3Exception.class, () -> amazonS3Service.getFile(imageUrl)); + Assertions.assertThrows(NoSuchKeyException.class, () -> amazonS3Service.getFile(imageUrl)); } @Test @@ -148,7 +162,7 @@ void renameFile(TestInfo testInfo) throws IOException, InterruptedException { Assertions.assertEquals(newKey, renameFile); // We will get a AmazonS3Exception 404 error if the key doesn't exist. - Assertions.assertThrows(AmazonS3Exception.class, () -> amazonS3Service.getFile(imageUrl)); + Assertions.assertThrows(NoSuchKeyException.class, () -> amazonS3Service.getFile(imageUrl)); try (InputStream storedImageUrl = amazonS3Service.getFile(newKey)) { Assertions.assertNotNull(storedImageUrl); diff --git a/src/integrationTest/resources/application-integration-test.yml b/src/integrationTest/resources/application-integration-test.yml index c7bba9e..da5d201 100644 --- a/src/integrationTest/resources/application-integration-test.yml +++ b/src/integrationTest/resources/application-integration-test.yml @@ -26,4 +26,8 @@ spring: port: 3025 username: ${EMAIL_USERNAME:admin} password: ${EMAIL_PASSWORD:admin} - protocol: smtp \ No newline at end of file + protocol: smtp + +aws: + access-key-id: accessKey + secret-access-key: secretKey \ No newline at end of file diff --git a/src/main/java/com/developersboard/backend/service/mail/impl/SmtpEmailServiceImpl.java b/src/main/java/com/developersboard/backend/service/mail/impl/SmtpEmailServiceImpl.java index adf4126..937f724 100644 --- a/src/main/java/com/developersboard/backend/service/mail/impl/SmtpEmailServiceImpl.java +++ b/src/main/java/com/developersboard/backend/service/mail/impl/SmtpEmailServiceImpl.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; import org.springframework.context.annotation.Profile; +import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; @@ -78,7 +79,7 @@ public void sendHtmlEmailWithAttachment(final HtmlEmailRequest emailRequest) { mailSender.send(prepareMimeMessage(emailRequest)); LOG.info(EmailConstants.MAIL_SUCCESS_MESSAGE); - } catch (MessagingException | FileNotFoundException | UnsupportedEncodingException e) { + } catch (MailSendException | MessagingException | FileNotFoundException | UnsupportedEncodingException e) { throw new InvalidServiceRequestException(e); } } diff --git a/src/main/java/com/developersboard/config/core/ApplicationConfig.java b/src/main/java/com/developersboard/config/core/ApplicationConfig.java index 6b94006..e9caeed 100644 --- a/src/main/java/com/developersboard/config/core/ApplicationConfig.java +++ b/src/main/java/com/developersboard/config/core/ApplicationConfig.java @@ -7,6 +7,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.data.auditing.DateTimeProvider; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -43,6 +45,13 @@ public DateTimeProvider dateTimeProvider() { @Bean public S3Presigner s3Presigner(AwsProperties props) { - return S3Presigner.builder().region(Region.of(props.getRegion())).build(); + // Create the credential provider + var credentials = + AwsBasicCredentials.create(props.getAccessKeyId(), props.getSecretAccessKey()); + + return S3Presigner.builder() + .region(Region.of(props.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); } }