From dd77c7cf9050f616c8b91539b571421d02cb0fd7 Mon Sep 17 00:00:00 2001 From: agordon-vivid <159182586+agordon-vivid@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:40:46 -0800 Subject: [PATCH] Adding Springs AuditorAware capabilities (#368) --- .../wfprev/SpringSecurityAuditorAware.java | 29 +++++ .../gov/nrs/wfprev/WfprevApiApplication.java | 2 + .../wfprev/data/entities/ProjectEntity.java | 13 +- .../nrs/wfprev/services/ProjectService.java | 9 -- .../nrs/wfprev/CheckTokenControllerTest.java | 21 ++-- .../gov/nrs/wfprev/CodesControllerTest.java | 7 ++ .../nrs/wfprev/ProgramAreaControllerTest.java | 6 + .../wfprev/ProjectBoundaryControllerTest.java | 6 + .../gov/nrs/wfprev/ProjectControllerTest.java | 6 + .../SpringSecurityAuditorAwareTest.java | 114 ++++++++++++++++++ .../wfprev/services/ProjectServiceTest.java | 1 - 11 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAware.java create mode 100644 server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAwareTest.java diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAware.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAware.java new file mode 100644 index 000000000..b9c63ca65 --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAware.java @@ -0,0 +1,29 @@ +package ca.bc.gov.nrs.wfprev; + +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class SpringSecurityAuditorAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + return Optional.ofNullable(SecurityContextHolder.getContext()) + .map(context -> context.getAuthentication()) + .filter(Authentication::isAuthenticated) + .map(authentication -> { + Object principal = authentication.getPrincipal(); + if (principal instanceof DefaultOAuth2AuthenticatedPrincipal) { + // Extract username or preferred identifier + DefaultOAuth2AuthenticatedPrincipal oauthPrincipal = (DefaultOAuth2AuthenticatedPrincipal) principal; + return (String) oauthPrincipal.getAttribute("preferred_username"); // Adjust key to match your provider + } + throw new IllegalStateException("Principal is not of type DefaultOAuth2AuthenticatedPrincipal"); + }); + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/WfprevApiApplication.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/WfprevApiApplication.java index 3e6ec273c..f071ace8f 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/WfprevApiApplication.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/WfprevApiApplication.java @@ -7,6 +7,7 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.web.filter.ForwardedHeaderFilter; import com.fasterxml.jackson.annotation.JsonFormat; @@ -19,6 +20,7 @@ import jakarta.servlet.DispatcherType; @SpringBootApplication +@EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware") public class WfprevApiApplication { /* * Run the application as a JAR diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java index 868e10df6..a376c4be4 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -24,13 +25,14 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; import java.util.UUID; - @Entity +@EntityListeners(AuditingEntityListener.class) @Table(name = "project") @JsonIgnoreProperties(ignoreUnknown = false) @Data @@ -39,6 +41,7 @@ @AllArgsConstructor @NoArgsConstructor public class ProjectEntity implements Serializable { + @Id @UuidGenerator @GeneratedValue(strategy = GenerationType.UUID) @@ -49,7 +52,7 @@ public class ProjectEntity implements Serializable { @JoinColumn(name = "project_type_code") private ProjectTypeCodeEntity projectTypeCode; - @Column(name = "project_number", columnDefinition="Decimal(10)", insertable = false, updatable = true) + @Column(name = "project_number", columnDefinition = "Decimal(10)", insertable = false, updatable = true) private Integer projectNumber; @NotNull @@ -81,7 +84,7 @@ public class ProjectEntity implements Serializable { @Column(name = "bc_parks_region_org_unit_id", columnDefinition = "Decimal(10)") private Integer bcParksRegionOrgUnitId; - @Column(name = "bcParksSectionOrgUnitId", columnDefinition = "Decimal(10)") + @Column(name = "bc_parks_section_org_unit_id", columnDefinition = "Decimal(10)") private Integer bcParksSectionOrgUnitId; @NotNull @@ -144,7 +147,7 @@ public class ProjectEntity implements Serializable { private Integer revisionCount; @ManyToOne(fetch = FetchType.EAGER, optional = false) - @JoinColumn(name="project_status_code") + @JoinColumn(name = "project_status_code") private ProjectStatusCodeEntity projectStatusCode; @CreatedBy @@ -166,4 +169,4 @@ public class ProjectEntity implements Serializable { @NotNull @Column(name = "update_date") private Date updateDate; -} +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java index 340b312c2..e92bffd70 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java @@ -78,18 +78,9 @@ public ProjectModel createOrUpdateProject(ProjectModel resource) throws ServiceE try { if (resource.getProjectGuid() == null) { resource.setCreateDate(new Date()); - //TODO - Fix to use proper user - resource.setCreateUser("SYSTEM"); resource.setProjectGuid(UUID.randomUUID().toString()); - resource.setCreateDate(new Date()); - resource.setCreateUser("SYSTEM"); resource.setRevisionCount(0); // Initialize revision count for new records } - // Set audit fields - resource.setUpdateDate(new Date()); - //TODO - Fix to use proper user - resource.setUpdateUser("SYSTEM"); - ProjectEntity entity = projectResourceAssembler.toEntity(resource); // Load ForestAreaCode with null checks diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CheckTokenControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CheckTokenControllerTest.java index aa0669416..06244da9c 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CheckTokenControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CheckTokenControllerTest.java @@ -1,30 +1,37 @@ package ca.bc.gov.nrs.wfprev; +import ca.bc.gov.nrs.wfprev.common.controllers.CheckTokenController; 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.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.testcontainers.utility.TestcontainersConfiguration; -import ca.bc.gov.nrs.wfprev.common.controllers.CheckTokenController; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(CheckTokenController.class) @Import({SecurityConfig.class, TestcontainersConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) class CheckTokenControllerTest { @Autowired private MockMvc mockMvc; + @MockBean(name = "springSecurityAuditorAware") // Changed to match the expected bean name + private AuditorAware auditorAware; + @Test @WithMockUser void testToken_MissingAuthorizationHeader() throws Exception { mockMvc.perform(get("/check/checkToken") - .contentType(MediaType.APPLICATION_JSON)) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -32,8 +39,8 @@ void testToken_MissingAuthorizationHeader() throws Exception { @WithMockUser void testToken_NullTokenInHeader() throws Exception { mockMvc.perform(get("/check/checkToken") - .header("Authorization", "Bearer ") - .contentType(MediaType.APPLICATION_JSON)) + .header("Authorization", "Bearer ") + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } -} +} \ No newline at end of file diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CodesControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CodesControllerTest.java index e83536b6b..9f9b5f32f 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CodesControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/CodesControllerTest.java @@ -11,6 +11,9 @@ 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.data.domain.AuditorAware; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.data.jpa.util.JpaMetamodel; import org.springframework.hateoas.CollectionModel; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -29,6 +32,7 @@ @WebMvcTest(CodesController.class) @Import({SecurityConfig.class, TestcontainersConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) class CodesControllerTest { @MockBean @@ -37,6 +41,9 @@ class CodesControllerTest { @Autowired private MockMvc mockMvc; + @MockBean(name = "springSecurityAuditorAware") // Changed to match the expected bean name + private AuditorAware auditorAware; + @Test @WithMockUser void testGetAllCodes() throws Exception { diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProgramAreaControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProgramAreaControllerTest.java index 14fac5759..8e9e800d6 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProgramAreaControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProgramAreaControllerTest.java @@ -17,6 +17,8 @@ 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.data.domain.AuditorAware; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.hateoas.CollectionModel; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -40,6 +42,7 @@ @WebMvcTest(ProgramAreaController.class) @Import({TestSpringSecurity.class, TestcontainersConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) class ProgramAreaControllerTest { @MockBean private ProgramAreaService programAreaService; @@ -47,6 +50,9 @@ class ProgramAreaControllerTest { @Autowired private MockMvc mockMvc; + @MockBean(name = "springSecurityAuditorAware") // Changed to match the expected bean name + private AuditorAware auditorAware; + private Gson gson; @BeforeEach diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectBoundaryControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectBoundaryControllerTest.java index 709e792fd..785b41b71 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectBoundaryControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectBoundaryControllerTest.java @@ -20,6 +20,8 @@ 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.data.domain.AuditorAware; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.hateoas.CollectionModel; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -51,6 +53,7 @@ @WebMvcTest(ProjectBoundaryController.class) @Import({TestSpringSecurity.class, TestcontainersConfiguration.class, MockMvcRestExceptionConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) class ProjectBoundaryControllerTest { @MockBean private ProjectBoundaryService projectBoundaryService; @@ -58,6 +61,9 @@ class ProjectBoundaryControllerTest { @Autowired private MockMvc mockMvc; + @MockBean(name = "springSecurityAuditorAware") // Changed to match the expected bean name + private AuditorAware auditorAware; + private Gson gson; @BeforeEach diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java index 4effaa7ac..1c9f38b43 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java @@ -20,6 +20,8 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.hateoas.CollectionModel; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -42,6 +44,7 @@ @WebMvcTest(ProjectController.class) @Import({TestSpringSecurity.class, TestcontainersConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) class ProjectControllerTest { @MockBean private ProjectService projectService; @@ -49,6 +52,9 @@ class ProjectControllerTest { @Autowired private MockMvc mockMvc; + @MockBean(name = "springSecurityAuditorAware") // Changed to match the expected bean name + private AuditorAware auditorAware; + private Gson gson; @BeforeEach diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAwareTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAwareTest.java new file mode 100644 index 000000000..1926ae1cf --- /dev/null +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/SpringSecurityAuditorAwareTest.java @@ -0,0 +1,114 @@ +package ca.bc.gov.nrs.wfprev; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SpringSecurityAuditorAwareTest { + + private SpringSecurityAuditorAware auditorAware; + + private MockedStatic securityContextHolderMock; + + @BeforeEach + void setUp() { + auditorAware = new SpringSecurityAuditorAware(); + securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); + } + + @AfterEach + void tearDown() { + securityContextHolderMock.close(); + } + + @Test + void getCurrentAuditor_authenticatedUser_returnsUsername() { + // Given: A valid SecurityContext with an authenticated user + SecurityContext mockSecurityContext = mock(SecurityContext.class); + Authentication mockAuthentication = mock(Authentication.class); + DefaultOAuth2AuthenticatedPrincipal mockPrincipal = mock(DefaultOAuth2AuthenticatedPrincipal.class); + + when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); + when(mockSecurityContext.getAuthentication()).thenReturn(mockAuthentication); + when(mockAuthentication.isAuthenticated()).thenReturn(true); + when(mockAuthentication.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getAttribute("preferred_username")).thenReturn("test_user"); + + // When: getCurrentAuditor is called + Optional result = auditorAware.getCurrentAuditor(); + + // Then: The correct username is returned + assertEquals(Optional.of("test_user"), result); + } + + @Test + void getCurrentAuditor_userNotAuthenticated_returnsEmptyOptional() { + // Given: A valid SecurityContext with an unauthenticated user + SecurityContext mockSecurityContext = mock(SecurityContext.class); + Authentication mockAuthentication = mock(Authentication.class); + + when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); + when(mockSecurityContext.getAuthentication()).thenReturn(mockAuthentication); + when(mockAuthentication.isAuthenticated()).thenReturn(false); + + // When: getCurrentAuditor is called + Optional result = auditorAware.getCurrentAuditor(); + + // Then: An empty Optional is returned + assertEquals(Optional.empty(), result); + } + + @Test + void getCurrentAuditor_invalidPrincipalType_throwsIllegalStateException() { + // Given: A valid SecurityContext with an invalid principal type + SecurityContext mockSecurityContext = mock(SecurityContext.class); + Authentication mockAuthentication = mock(Authentication.class); + + when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); + when(mockSecurityContext.getAuthentication()).thenReturn(mockAuthentication); + when(mockAuthentication.isAuthenticated()).thenReturn(true); + when(mockAuthentication.getPrincipal()).thenReturn("InvalidPrincipal"); + + // When & Then: getCurrentAuditor throws an IllegalStateException + IllegalStateException exception = assertThrows(IllegalStateException.class, auditorAware::getCurrentAuditor); + assertEquals("Principal is not of type DefaultOAuth2AuthenticatedPrincipal", exception.getMessage()); + } + + @Test + void getCurrentAuditor_nullSecurityContext_returnsEmptyOptional() { + // Given: A null SecurityContext + when(SecurityContextHolder.getContext()).thenReturn(null); + + // When: getCurrentAuditor is called + Optional result = auditorAware.getCurrentAuditor(); + + // Then: An empty Optional is returned + assertEquals(Optional.empty(), result); + } + + @Test + void getCurrentAuditor_nullAuthentication_returnsEmptyOptional() { + // Given: A valid SecurityContext with a null Authentication + SecurityContext mockSecurityContext = mock(SecurityContext.class); + + when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); + when(mockSecurityContext.getAuthentication()).thenReturn(null); + + // When: getCurrentAuditor is called + Optional result = auditorAware.getCurrentAuditor(); + + // Then: An empty Optional is returned + assertEquals(Optional.empty(), result); + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java index f9d7e9df4..c8ac5bd2d 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java @@ -322,7 +322,6 @@ public void test_create_new_project_with_null_guid() throws ServiceException { "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" )); - assertEquals("SYSTEM", capturedModel.getCreateUser()); assertNotNull("Create date should be set", capturedModel.getCreateDate()); verify(projectRepository).saveAndFlush(any(ProjectEntity.class)); verify(projectResourceAssembler).toModel(any(ProjectEntity.class));