From cba03533dc9ee0f1e98fbb224a2f9d888bc3ec9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:25:07 +0000 Subject: [PATCH 1/3] Initial plan From 35b81fe2fa194aaf9fb4afbab67197a15638870d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:46:51 +0000 Subject: [PATCH 2/3] test: add missing tests for JobController, JwtTokenProvider, JwtAuthenticationFilter, and EncryptionService Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/58805c8b-a639-40f3-94b0-c345d1bedbc3 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- .../config/EncryptionServiceTest.kt | 55 +++++++++ .../controller/JobControllerTest.kt | 106 ++++++++++++++++++ .../security/JwtAuthenticationFilterTest.kt | 92 +++++++++++++++ .../security/JwtTokenProviderTest.kt | 58 ++++++++++ 4 files changed, 311 insertions(+) create mode 100644 backend/src/test/kotlin/com/opendatamask/config/EncryptionServiceTest.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/security/JwtAuthenticationFilterTest.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/security/JwtTokenProviderTest.kt diff --git a/backend/src/test/kotlin/com/opendatamask/config/EncryptionServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/config/EncryptionServiceTest.kt new file mode 100644 index 0000000..a585489 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/config/EncryptionServiceTest.kt @@ -0,0 +1,55 @@ +package com.opendatamask.config + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class EncryptionServiceTest { + + private lateinit var encryptionService: EncryptionService + + @BeforeEach + fun setup() { + encryptionService = EncryptionService("0123456789abcdef") + } + + @Test + fun `encrypt returns non-blank ciphertext`() { + val ciphertext = encryptionService.encrypt("hello world") + assertNotNull(ciphertext) + assertTrue(ciphertext.isNotBlank()) + } + + @Test + fun `decrypt reverses encrypt for plain text`() { + val original = "my secret value" + val ciphertext = encryptionService.encrypt(original) + assertEquals(original, encryptionService.decrypt(ciphertext)) + } + + @Test + fun `encrypt produces different ciphertext on each call due to random IV`() { + val plaintext = "same input" + val first = encryptionService.encrypt(plaintext) + val second = encryptionService.encrypt(plaintext) + assertNotEquals(first, second) + } + + @Test + fun `encrypt and decrypt handle empty string`() { + val ciphertext = encryptionService.encrypt("") + assertEquals("", encryptionService.decrypt(ciphertext)) + } + + @Test + fun `encrypt and decrypt handle special characters`() { + val original = "p@ssw0rd!#\$%^&*()" + assertEquals(original, encryptionService.decrypt(encryptionService.encrypt(original))) + } + + @Test + fun `encrypt and decrypt handle unicode`() { + val original = "こんにちは世界" + assertEquals(original, encryptionService.decrypt(encryptionService.encrypt(original))) + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt b/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt new file mode 100644 index 0000000..5d35715 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt @@ -0,0 +1,106 @@ +package com.opendatamask.controller + +import com.opendatamask.dto.JobLogResponse +import com.opendatamask.dto.JobResponse +import com.opendatamask.model.JobStatus +import com.opendatamask.model.LogLevel +import com.opendatamask.repository.UserRepository +import com.opendatamask.security.JwtTokenProvider +import com.opendatamask.security.UserDetailsServiceImpl +import com.opendatamask.service.JobService +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import java.time.LocalDateTime + +@WebMvcTest( + JobController::class, + excludeAutoConfiguration = [SecurityAutoConfiguration::class, SecurityFilterAutoConfiguration::class] +) +@ActiveProfiles("test") +class JobControllerTest { + + @Autowired private lateinit var mockMvc: MockMvc + + @MockBean private lateinit var jobService: JobService + @MockBean private lateinit var userRepository: UserRepository + @MockBean private lateinit var jwtTokenProvider: JwtTokenProvider + @MockBean private lateinit var userDetailsServiceImpl: UserDetailsServiceImpl + + private fun makeJobResponse(id: Long = 1L, workspaceId: Long = 1L, status: JobStatus = JobStatus.PENDING) = + JobResponse( + id = id, + workspaceId = workspaceId, + status = status, + startedAt = null, + completedAt = null, + createdAt = LocalDateTime.now(), + errorMessage = null, + createdBy = 1L + ) + + private fun makeJobLogResponse(id: Long = 1L, jobId: Long = 1L) = + JobLogResponse( + id = id, + jobId = jobId, + message = "Job started", + level = LogLevel.INFO, + timestamp = LocalDateTime.now() + ) + + @Test + fun `GET list jobs returns 200`() { + whenever(jobService.listJobs(1L)).thenReturn(listOf(makeJobResponse(id = 1L), makeJobResponse(id = 2L))) + + mockMvc.perform(get("/api/workspaces/1/jobs")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + } + + @Test + fun `GET job by id returns 200`() { + whenever(jobService.getJob(1L, 1L)).thenReturn(makeJobResponse(id = 1L, status = JobStatus.COMPLETED)) + + mockMvc.perform(get("/api/workspaces/1/jobs/1")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.status").value("COMPLETED")) + } + + @Test + fun `GET job by id returns 404 when not found`() { + whenever(jobService.getJob(1L, 99L)).thenThrow(NoSuchElementException("Job not found: 99")) + + mockMvc.perform(get("/api/workspaces/1/jobs/99")) + .andExpect(status().isNotFound) + } + + @Test + fun `GET job logs returns 200`() { + whenever(jobService.getJobLogs(1L, 1L)).thenReturn( + listOf(makeJobLogResponse(id = 1L), makeJobLogResponse(id = 2L)) + ) + + mockMvc.perform(get("/api/workspaces/1/jobs/1/logs")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].message").value("Job started")) + } + + @Test + fun `POST cancel job returns 200`() { + whenever(jobService.cancelJob(1L, 1L)).thenReturn(makeJobResponse(id = 1L, status = JobStatus.CANCELLED)) + + mockMvc.perform(post("/api/workspaces/1/jobs/1/cancel")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.status").value("CANCELLED")) + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/security/JwtAuthenticationFilterTest.kt b/backend/src/test/kotlin/com/opendatamask/security/JwtAuthenticationFilterTest.kt new file mode 100644 index 0000000..5367916 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/security/JwtAuthenticationFilterTest.kt @@ -0,0 +1,92 @@ +package com.opendatamask.security + +import jakarta.servlet.FilterChain +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.* +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User + +@ExtendWith(MockitoExtension::class) +class JwtAuthenticationFilterTest { + + @Mock private lateinit var jwtTokenProvider: JwtTokenProvider + @Mock private lateinit var userDetailsService: UserDetailsServiceImpl + @Mock private lateinit var filterChain: FilterChain + + @InjectMocks + private lateinit var filter: JwtAuthenticationFilter + + @BeforeEach + fun clearSecurityContext() { + SecurityContextHolder.clearContext() + } + + @Test + fun `valid token sets authentication in security context`() { + val request = MockHttpServletRequest() + request.addHeader("Authorization", "Bearer valid.token.here") + val response = MockHttpServletResponse() + + val userDetails = User("alice", "password", listOf(SimpleGrantedAuthority("ROLE_USER"))) + whenever(jwtTokenProvider.validateToken("valid.token.here")).thenReturn(true) + whenever(jwtTokenProvider.getUsernameFromToken("valid.token.here")).thenReturn("alice") + whenever(userDetailsService.loadUserByUsername("alice")).thenReturn(userDetails) + + filter.doFilter(request, response, filterChain) + + val auth = SecurityContextHolder.getContext().authentication + assertNotNull(auth) + assertTrue(auth.isAuthenticated) + assertEquals("alice", (auth.principal as org.springframework.security.core.userdetails.UserDetails).username) + verify(filterChain).doFilter(request, response) + } + + @Test + fun `invalid token does not set authentication`() { + val request = MockHttpServletRequest() + request.addHeader("Authorization", "Bearer invalid.token") + val response = MockHttpServletResponse() + + whenever(jwtTokenProvider.validateToken("invalid.token")).thenReturn(false) + + filter.doFilter(request, response, filterChain) + + assertNull(SecurityContextHolder.getContext().authentication) + verify(filterChain).doFilter(request, response) + verify(jwtTokenProvider, never()).getUsernameFromToken(any()) + } + + @Test + fun `missing Authorization header continues filter chain without authentication`() { + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + + filter.doFilter(request, response, filterChain) + + assertNull(SecurityContextHolder.getContext().authentication) + verify(filterChain).doFilter(request, response) + verify(jwtTokenProvider, never()).validateToken(any()) + } + + @Test + fun `non-Bearer Authorization header is ignored`() { + val request = MockHttpServletRequest() + request.addHeader("Authorization", "Basic dXNlcjpwYXNz") + val response = MockHttpServletResponse() + + filter.doFilter(request, response, filterChain) + + assertNull(SecurityContextHolder.getContext().authentication) + verify(filterChain).doFilter(request, response) + verify(jwtTokenProvider, never()).validateToken(any()) + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/security/JwtTokenProviderTest.kt b/backend/src/test/kotlin/com/opendatamask/security/JwtTokenProviderTest.kt new file mode 100644 index 0000000..e6bb862 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/security/JwtTokenProviderTest.kt @@ -0,0 +1,58 @@ +package com.opendatamask.security + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class JwtTokenProviderTest { + + private lateinit var jwtTokenProvider: JwtTokenProvider + + private val secret = "test-jwt-secret-for-testing-only-not-production" + private val expiration = 86400000L // 24 hours + + @BeforeEach + fun setup() { + jwtTokenProvider = JwtTokenProvider(secret, expiration) + } + + @Test + fun `generateToken returns non-blank token`() { + val token = jwtTokenProvider.generateToken("alice") + assertNotNull(token) + assertTrue(token.isNotBlank()) + } + + @Test + fun `getUsernameFromToken returns correct username`() { + val token = jwtTokenProvider.generateToken("alice") + assertEquals("alice", jwtTokenProvider.getUsernameFromToken(token)) + } + + @Test + fun `validateToken returns true for a fresh token`() { + val token = jwtTokenProvider.generateToken("bob") + assertTrue(jwtTokenProvider.validateToken(token)) + } + + @Test + fun `validateToken returns false for an expired token`() { + val expiredProvider = JwtTokenProvider(secret, jwtExpiration = -1L) + val token = expiredProvider.generateToken("charlie") + assertFalse(expiredProvider.validateToken(token)) + } + + @Test + fun `validateToken returns false for a tampered token`() { + val token = jwtTokenProvider.generateToken("dave") + val tampered = token.dropLast(5) + "XXXXX" + assertFalse(jwtTokenProvider.validateToken(tampered)) + } + + @Test + fun `generateToken produces different tokens for different users`() { + val token1 = jwtTokenProvider.generateToken("user1") + val token2 = jwtTokenProvider.generateToken("user2") + assertNotEquals(token1, token2) + } +} From 86a3467651a4ab93314ce381c5a83129df0743fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:32:48 +0000 Subject: [PATCH 3/3] fix: add createAndRunJob test and include EncryptionService in JaCoCo coverage Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/190eb898-bd59-4574-9240-605c65c31c00 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- backend/build.gradle.kts | 4 +- .../controller/JobControllerTest.kt | 45 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 2d55177..a0b43d8 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -64,7 +64,9 @@ tasks.jacocoTestReport { files(classDirectories.files.map { fileTree(it) { exclude( - "**/config/**", + "**/config/SecurityConfig*", + "**/config/GlobalExceptionHandler*", + "**/config/StartupSecurityValidator*", "**/OpenDataMaskApplication*", "**/model/**" ) diff --git a/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt b/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt index 5d35715..0dc4c38 100644 --- a/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/controller/JobControllerTest.kt @@ -4,6 +4,7 @@ import com.opendatamask.dto.JobLogResponse import com.opendatamask.dto.JobResponse import com.opendatamask.model.JobStatus import com.opendatamask.model.LogLevel +import com.opendatamask.model.User import com.opendatamask.repository.UserRepository import com.opendatamask.security.JwtTokenProvider import com.opendatamask.security.UserDetailsServiceImpl @@ -13,21 +14,46 @@ import org.mockito.kotlin.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.web.SecurityFilterChain import org.springframework.test.context.ActiveProfiles import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* import java.time.LocalDateTime +import java.util.Optional @WebMvcTest( JobController::class, - excludeAutoConfiguration = [SecurityAutoConfiguration::class, SecurityFilterAutoConfiguration::class] + excludeAutoConfiguration = [ + SecurityAutoConfiguration::class, + SecurityFilterAutoConfiguration::class, + UserDetailsServiceAutoConfiguration::class + ] ) +@Import(JobControllerTest.TestSecurityConfig::class) @ActiveProfiles("test") class JobControllerTest { + @TestConfiguration + @EnableWebSecurity + class TestSecurityConfig { + @Bean + fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http.csrf { it.disable() } + .authorizeHttpRequests { it.anyRequest().permitAll() } + return http.build() + } + } + @Autowired private lateinit var mockMvc: MockMvc @MockBean private lateinit var jobService: JobService @@ -103,4 +129,21 @@ class JobControllerTest { .andExpect(status().isOk) .andExpect(jsonPath("$.status").value("CANCELLED")) } + + @Test + @WithMockUser(username = "alice") + fun `POST create and run job returns 201`() { + val mockUser = User(id = 1L, username = "alice", email = "alice@example.com", passwordHash = "hash") + whenever(userRepository.findByUsername("alice")).thenReturn(Optional.of(mockUser)) + whenever(jobService.createJob(1L, 1L)).thenReturn(makeJobResponse(id = 1L, status = JobStatus.PENDING)) + doNothing().whenever(jobService).runJob(1L) + + mockMvc.perform(post("/api/workspaces/1/jobs")) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.status").value("PENDING")) + + verify(jobService).createJob(1L, 1L) + verify(jobService).runJob(1L) + } }