-
Notifications
You must be signed in to change notification settings - Fork 0
Add missing tests for JobController, JwtTokenProvider, JwtAuthenticationFilter, and EncryptionService #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| } | ||
|
|
||
| @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
|
||
|
|
||
| @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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.ktsconfig excludes**/config/**from JaCoCoclassDirectories, andEncryptionServicelives undercom.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.