From 7721522422740ef68460bf5aac68e1dd293ac356 Mon Sep 17 00:00:00 2001 From: flawmop Date: Fri, 30 Aug 2024 18:06:14 -0700 Subject: [PATCH] Add JPA inc auditing --- build.gradle | 27 ++++++- .../portal/svc/rip/config/AsyncConfig.java | 10 +-- .../svc/rip/config/JpaAuditingConfig.java | 39 ++++++++++ .../controller/FileAsyncUploadController.java | 2 +- .../rip/persistence/entity/Simulation.java | 77 +++++++++++++++++++ .../repository/SimulationRepository.java | 14 ++++ .../rip/service/InputProcessorService.java | 9 ++- .../service/InputProcessorServiceImpl.java | 12 ++- src/main/resources/application.yml | 10 +++ ...onTests.java => RipSvcApplicationE2E.java} | 2 +- src/test-e2e/resources/application-e2e.yml | 3 + .../FileAsyncUploadControllerIT.java | 7 +- .../repository/SimulationRepositoryIT.java | 50 ++++++++++++ .../service/InputProcessorServiceTest.java | 71 +++++++++++++++++ 14 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/insilicosoft/portal/svc/rip/config/JpaAuditingConfig.java create mode 100644 src/main/java/com/insilicosoft/portal/svc/rip/persistence/entity/Simulation.java create mode 100644 src/main/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepository.java rename src/test-e2e/java/com/insilicosoft/portal/svc/rip/{RipSvcApplicationTests.java => RipSvcApplicationE2E.java} (92%) create mode 100644 src/test-e2e/resources/application-e2e.yml create mode 100644 src/test-i/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepositoryIT.java create mode 100644 src/test/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceTest.java diff --git a/build.gradle b/build.gradle index a43d594..0c0f821 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,8 @@ gitProperties { ext { set('springCloudVersion', "2023.0.0") - set('testKeycloakVersion', "2.3.0") + set('tcKeycloakVersion', "2.3.0") + set('tcPostgresVersion', "1.17.3") } configurations { @@ -44,14 +45,16 @@ configurations { dependencies { implementation 'io.micrometer:micrometer-registry-prometheus:1.13.3' - implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-web' // Note: Introduces commons-logging conflict with spring-boot-starter-web! implementation 'org.springframework.cloud:spring-cloud-stream-binder-rabbit' + runtimeOnly 'org.postgresql:postgresql' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' } @@ -59,6 +62,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + mavenBom "org.testcontainers:testcontainers-bom:${tcPostgresVersion}" } } @@ -83,10 +87,17 @@ testing { java { srcDirs = ['src/test-i/java'] } + resources { + srcDirs = ['src/test-i/resources'] + } } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-testcontainers' implementation 'org.springframework.security:spring-security-test' + implementation 'org.testcontainers:junit-jupiter' + implementation "org.testcontainers:postgresql:${tcPostgresVersion}" } } @@ -100,10 +111,18 @@ testing { } } dependencies { + implementation "com.github.dasniko:testcontainers-keycloak:${tcKeycloakVersion}" implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.security:spring-security-test' implementation 'org.testcontainers:junit-jupiter' - implementation "com.github.dasniko:testcontainers-keycloak:${testKeycloakVersion}" + implementation "org.testcontainers:postgresql:${tcPostgresVersion}" + } + targets.configureEach { + testTask.configure { + environment("SPRING_PROFILES_ACTIVE", "e2e") + // For extra debugging, use below with logging.level.root=TRACE in application-e2e.yml + // testLogging.showStandardStreams = true + } } } } @@ -150,4 +169,4 @@ tasks.named('test') { tasks.withType(JavaCompile) { options.encoding = 'UTF-8' -} +} \ No newline at end of file diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/config/AsyncConfig.java b/src/main/java/com/insilicosoft/portal/svc/rip/config/AsyncConfig.java index 2f2b89b..f358fb2 100644 --- a/src/main/java/com/insilicosoft/portal/svc/rip/config/AsyncConfig.java +++ b/src/main/java/com/insilicosoft/portal/svc/rip/config/AsyncConfig.java @@ -1,7 +1,5 @@ package com.insilicosoft.portal.svc.rip.config; -import java.util.concurrent.Executor; - import org.slf4j.Logger ; import org.slf4j.LoggerFactory ; import org.springframework.beans.factory.annotation.Value ; @@ -9,6 +7,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor; /** * Asynchronous configuration. @@ -46,12 +45,13 @@ public class AsyncConfig { } /** - * Create the {@link ThreadPoolTaskExecutor}. + * Create a {@link DelegatingSecurityContextAsyncTaskExecutor} as we want to be passing a + * security {@code Authentication} object to asynchronous methods (e.g. for JPA auditing purposes). * * @return Created executor. */ @Bean - Executor taskExecutor() { + DelegatingSecurityContextAsyncTaskExecutor taskExecutor() { final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(corePoolSize); executor.setMaxPoolSize(maxPoolSize); @@ -62,7 +62,7 @@ Executor taskExecutor() { log.debug("~taskExecutor() : Executor core pool size '{}', max pool size '{}', queue capacity '{}', thread name prefix '{}'", corePoolSize, maxPoolSize, queueCapacity, threadNamePrefix); - return executor; + return new DelegatingSecurityContextAsyncTaskExecutor(executor); } } \ No newline at end of file diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/config/JpaAuditingConfig.java b/src/main/java/com/insilicosoft/portal/svc/rip/config/JpaAuditingConfig.java new file mode 100644 index 0000000..b1ba6d6 --- /dev/null +++ b/src/main/java/com/insilicosoft/portal/svc/rip/config/JpaAuditingConfig.java @@ -0,0 +1,39 @@ +package com.insilicosoft.portal.svc.rip.config; + +import java.util.Optional; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +/** + * JPA Auditing configuration. + *

+ * Contains mechanism to map the JWT token claims to an identity for auditing purposes. + * + * @author geoff + */ +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +public class JpaAuditingConfig { + + @Bean + AuditorAware auditorAware() { + return new AuditorAware() { + @Override + public Optional getCurrentAuditor() { + String auditor = null; + if (SecurityContextHolder.getContext().getAuthentication() != null) { + JwtAuthenticationToken token = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + // Use the JWT 'subject' claim as identifier for audit purposes! + auditor = token.getName(); + } + return Optional.ofNullable(auditor); + } + }; + } + +} \ No newline at end of file diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadController.java b/src/main/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadController.java index 6e721f5..87d3081 100644 --- a/src/main/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadController.java +++ b/src/main/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadController.java @@ -37,7 +37,7 @@ public class FileAsyncUploadController { * * @param inputProcessorService Input processing implementation. */ - public FileAsyncUploadController(InputProcessorService inputProcessorService) { + public FileAsyncUploadController(final InputProcessorService inputProcessorService) { this.inputProcessorService = inputProcessorService; } diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/persistence/entity/Simulation.java b/src/main/java/com/insilicosoft/portal/svc/rip/persistence/entity/Simulation.java new file mode 100644 index 0000000..2b2af79 --- /dev/null +++ b/src/main/java/com/insilicosoft/portal/svc/rip/persistence/entity/Simulation.java @@ -0,0 +1,77 @@ +package com.insilicosoft.portal.svc.rip.persistence.entity; + +import static jakarta.persistence.GenerationType.IDENTITY; + +import java.time.Instant; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; + +/** + * Simulation entity. + * + * @author geoff + */ +@Entity +@EntityListeners(AuditingEntityListener.class) +@Table(name = "simulation") +public class Simulation { + + @Id + @Column(name = "id") + @GeneratedValue(strategy = IDENTITY) + private Long entityId; + + // See JPA auditing + @CreatedDate + Instant createdDate; + + // See JPA auditing + @LastModifiedDate + Instant lastModifiedDate; + + // See JPA auditing + @CreatedBy + String createdBy; + + // See JPA auditing + @LastModifiedBy + String lastModifiedBy; + + @Version + int version; + + public Simulation() {} + + // Getters/Setters + + /** + * Retrieve the entity id. + * + * @return Id of entity. + */ + public Long getEntityId() { + return entityId; + } + + // Boilerplate implementations + + @Override + public String toString() { + return "Simulation [entityId=" + entityId + ", createdDate=" + createdDate + ", lastModifiedDate=" + + lastModifiedDate + ", createdBy=" + createdBy + ", lastModifiedBy=" + lastModifiedBy + ", version=" + version + + "]"; + } + +} \ No newline at end of file diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepository.java b/src/main/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepository.java new file mode 100644 index 0000000..ceb40a2 --- /dev/null +++ b/src/main/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepository.java @@ -0,0 +1,14 @@ +package com.insilicosoft.portal.svc.rip.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.insilicosoft.portal.svc.rip.persistence.entity.Simulation; + +/** + * Repository for {@link Simulation} objects. + * + * @author geoff + */ +public interface SimulationRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorService.java b/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorService.java index 0c26688..0535563 100644 --- a/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorService.java +++ b/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorService.java @@ -1,5 +1,7 @@ package com.insilicosoft.portal.svc.rip.service; +import org.springframework.security.core.Authentication; + import com.insilicosoft.portal.svc.rip.exception.FileProcessingException; /** @@ -13,9 +15,12 @@ public interface InputProcessorService { public String get(); /** - * Process a file asyncronously (e.g. using the method {@code @Async} notation) a byte array). + * Process a file asynchronously (e.g. using the method {@code @Async} notation) a byte array). + *

+ * For auditing purposes, there MUST be an {@link Authentication} object returned by + * {@code SecurityContextHolder.getContext().getAuthentication()} when this method runs. * - * @param file Byte array file. + * @param file Non-{@code null} byte array. * @throws FileProcessingException If file processing problems. */ public void processAsync(byte[] file) throws FileProcessingException; diff --git a/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceImpl.java b/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceImpl.java index f108b51..c1b74e6 100644 --- a/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceImpl.java +++ b/src/main/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceImpl.java @@ -18,6 +18,8 @@ import com.fasterxml.jackson.core.JsonToken; import com.insilicosoft.portal.svc.rip.event.SimulationMessage; import com.insilicosoft.portal.svc.rip.exception.FileProcessingException; +import com.insilicosoft.portal.svc.rip.persistence.entity.Simulation; +import com.insilicosoft.portal.svc.rip.persistence.repository.SimulationRepository; enum FieldsSections { simulations @@ -40,14 +42,18 @@ public class InputProcessorServiceImpl implements InputProcessorService { private static final Logger log = LoggerFactory.getLogger(InputProcessorServiceImpl.class); + private final SimulationRepository simulationRepository; private final StreamBridge streamBridge; /** * Initialising constructor. * + * @param simulationRepository Simulation repository. * @param streamBridge Event sending mechanism. */ - public InputProcessorServiceImpl(StreamBridge streamBridge) { + public InputProcessorServiceImpl(final SimulationRepository simulationRepository, + final StreamBridge streamBridge) { + this.simulationRepository = simulationRepository; this.streamBridge = streamBridge; } @@ -94,11 +100,13 @@ public void processAsync(final byte[] file) throws FileProcessingException { // Verify the input was good + // Record the simulation + log.debug("~processAsync() : Saved : " + simulationRepository.save(new Simulation()).toString()); + // Fire off events for, e.g. simulation runners and databases for (SimulationMessage simulationMessage: simulations) { streamBridge.send(BINDING_NAME_SIMULATION_INPUT, simulationMessage); } - } void parseSimulations(JsonParser jsonParser, List simulations) throws IOException { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0074fb0..141027a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,6 +45,16 @@ spring: # Name below referenced internally and by StreamBridge simulation-input: destination: simulation-invoke + datasource: + hikari: + connection-timeout: 2000 #ms + maximum-pool-size: 5 + password: password + url: jdbc:postgresql://localhost:5432/portaldb + username: user + jpa: + hibernate: + ddl-auto: create-drop rabbitmq: host: localhost port: 5672 diff --git a/src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationTests.java b/src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationE2E.java similarity index 92% rename from src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationTests.java rename to src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationE2E.java index a6496d8..e33cd53 100644 --- a/src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationTests.java +++ b/src/test-e2e/java/com/insilicosoft/portal/svc/rip/RipSvcApplicationE2E.java @@ -6,7 +6,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; @SpringBootTest -class RipSvcApplicationTests { +class RipSvcApplicationE2E { @MockBean private JwtDecoder jwtDecoder; diff --git a/src/test-e2e/resources/application-e2e.yml b/src/test-e2e/resources/application-e2e.yml new file mode 100644 index 0000000..cfbaf33 --- /dev/null +++ b/src/test-e2e/resources/application-e2e.yml @@ -0,0 +1,3 @@ +spring: + datasource: + url: jdbc:tc:postgresql:14.4:/// \ No newline at end of file diff --git a/src/test-i/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadControllerIT.java b/src/test-i/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadControllerIT.java index 598a3dc..9ec5365 100644 --- a/src/test-i/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadControllerIT.java +++ b/src/test-i/java/com/insilicosoft/portal/svc/rip/controller/FileAsyncUploadControllerIT.java @@ -1,7 +1,7 @@ package com.insilicosoft.portal.svc.rip.controller; -import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; //import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -39,9 +39,8 @@ public class FileAsyncUploadControllerIT { @MockBean private InputProcessorService mockInputProcessorService; - @MockBean - private JwtDecoder jwtDecoder; + private JwtDecoder mockJwtDecoder; @DisplayName("Test GET method(s)") @Nested @@ -51,7 +50,7 @@ class GetMethods { void testGet() throws Exception { final String getMessage = "Message from get!"; - given(mockInputProcessorService.get()).willReturn(getMessage); + when(mockInputProcessorService.get()).thenReturn(getMessage); mockMvc.perform(get(RipIdentifiers.REQUEST_MAPPING_RUN).with(jwt().authorities(userRole))) //.andDo(print()) diff --git a/src/test-i/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepositoryIT.java b/src/test-i/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepositoryIT.java new file mode 100644 index 0000000..d86cc50 --- /dev/null +++ b/src/test-i/java/com/insilicosoft/portal/svc/rip/persistence/repository/SimulationRepositoryIT.java @@ -0,0 +1,50 @@ +package com.insilicosoft.portal.svc.rip.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; + +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.insilicosoft.portal.svc.rip.persistence.entity.Simulation; + +// TODO: Test passes, but HikariPool generating PSQLException/ConnectException + +// Note: By default, tests annotated with {@code @DataJpaTest} are transactional and roll back at the end of each test +@DataJpaTest +// We're providing the testcontainer as the datasource.... don't replace it with a default embedded! +@AutoConfigureTestDatabase(replace = NONE) +@Testcontainers +class SimulationRepositoryIT { + + @Autowired + private SimulationRepository simulationRepository; + + @Container + // https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections + @ServiceConnection(type = JdbcConnectionDetails.class) + private static PostgreSQLContainer postgresTc = new PostgreSQLContainer<>("postgres:14.4"); + + //@DynamicPropertySource + //static void configureProperties(DynamicPropertyRegistry registry) { + // registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + //} + + @Test + void retrieveSimulation() { + simulationRepository.save(new Simulation()); + + assertThat(simulationRepository.findById(1l)).isNotNull(); + } + +} \ No newline at end of file diff --git a/src/test/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceTest.java b/src/test/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceTest.java new file mode 100644 index 0000000..8b5de57 --- /dev/null +++ b/src/test/java/com/insilicosoft/portal/svc/rip/service/InputProcessorServiceTest.java @@ -0,0 +1,71 @@ +package com.insilicosoft.portal.svc.rip.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.stream.function.StreamBridge; + +import com.insilicosoft.portal.svc.rip.exception.FileProcessingException; +import com.insilicosoft.portal.svc.rip.persistence.entity.Simulation; +import com.insilicosoft.portal.svc.rip.persistence.repository.SimulationRepository; + +@ExtendWith(MockitoExtension.class) +public class InputProcessorServiceTest { + + private InputProcessorService inputProcessorService; + + @Mock + private Simulation mockSimulation; + @Mock + private SimulationRepository mockSimulationRepository; + @Mock + private StreamBridge mockStreamBridge; + + @BeforeEach + void setUp() { + this.inputProcessorService = new InputProcessorServiceImpl(mockSimulationRepository, + mockStreamBridge); + } + + @DisplayName("Fail if content is not valid.") + @Test + void testFailIfInvalidFileContent() { + // Application-defined exception message + final byte[] file1 = new byte[0]; + String message = "Content must be a JSON object"; + FileProcessingException e = assertThrows(FileProcessingException.class, () -> { + inputProcessorService.processAsync(file1); + }); + assertThat(e.getMessage()).isEqualTo(message); + + // Library-defined exception message + final byte[] file2 = "{".getBytes(); + message = "Unexpected end-of-input"; + e = assertThrows(FileProcessingException.class, () -> { + inputProcessorService.processAsync(file2); + }); + assertThat(e.getMessage()).startsWith(message); + } + + @DisplayName("Success if content is valid.") + @Test + void testSuccessIfValidFileContent() throws FileProcessingException { + + when(mockSimulationRepository.save(any(Simulation.class))) + .thenReturn(mockSimulation); + // No simulations generated! + + final byte[] file = "{}".getBytes(); + inputProcessorService.processAsync(file); + + } + +} \ No newline at end of file