Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ tasks.jacocoTestReport {
files(classDirectories.files.map {
fileTree(it) {
exclude(
"**/config/**",
"**/config/SecurityConfig*",
"**/config/GlobalExceptionHandler*",
"**/config/StartupSecurityValidator*",
"**/OpenDataMaskApplication*",
"**/model/**"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Comment on lines +7 to +14
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests may not affect your reported coverage metrics: backend/build.gradle.kts config excludes **/config/** from JaCoCo classDirectories, and EncryptionService lives under com.opendatamask.config. If the goal is to raise coverage for EncryptionService in CI reports, consider removing that exclusion or moving EncryptionService out of the excluded package.

Copilot uses AI. Check for mistakes.

@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)))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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.model.User
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.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,
UserDetailsServiceAutoConfiguration::class
]
)
@Import(JobControllerTest.TestSecurityConfig::class)
@ActiveProfiles("test")
class JobControllerTest {
Comment on lines +34 to +44
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JobController defines a POST /api/workspaces/{workspaceId}/jobs endpoint (createAndRunJob) returning 201, but this test class only covers list/get/logs/cancel. Either add a WebMvc test for the create endpoint (including setting an AuthenticationPrincipal and stubbing userRepository.findByUsername, plus verifying jobService.createJob and jobService.runJob are invoked) or update the PR description/claim about covering all endpoints.

Copilot uses AI. Check for mistakes.

@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
@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"))
}

@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)
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading