I'm working on a Kotlin spring boot project. It is an API for handling products and orders. I have been following this guide closely. Everything has been great until I wanted to add HTTPS instead of HTTP.
I added the spring-boot-starter-security dependency in my build.gradle file and added a basic security configuration.
build.gradle file:
plugins {
id("org.springframework.boot") version "3.0.4"
id("io.spring.dependency-management") version "1.1.0"
kotlin("jvm") version "1.7.22"
kotlin("plugin.spring") version "1.7.22"
kotlin("plugin.jpa") version "1.7.22"
}
...
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-hateoas")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-security")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2")
}
...
Security configuration:
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
@Configuration
class SecurityConfig {
@Bean
@Throws(Exception::class)
fun filterChain(http: HttpSecurity): SecurityFilterChain {
return http.build()
}
}
And all of this works when I run the backend application. I can do all curl operations with no issue. However, almost all of my RestController tests suddenly don't work.
In my testing suite I'm using TestRestTemplate and a JpaRepository (the implementation is simply interface OrderRepository : JpaRepository<Order, Long>). Here are a two example tests:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.*
import org.springframework.http.*
import org.springframework.test.annotation.DirtiesContext
import project.github.backend.order.Order
import project.github.backend.order.OrderRepository
import project.github.backend.order.Status
import javax.net.ssl.HttpsURLConnection
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.config.location=classpath:application-test.properties",
]
)
class OrderControllerTest(
@Autowired val client: TestRestTemplate,
@Autowired val orderRepository: OrderRepository,
) {
@BeforeEach
fun setup() {
HttpsURLConnection.setDefaultHostnameVerifier { hostname, _ ->
hostname == "localhost"
}
}
@Test
fun `cancelling an already cancelled order returns 405 method not allowed`() {
val order = Order(emptyList())
order.setStatus(Status.CANCELLED)
orderRepository.save(order)
val invalidCancelMethod = client.exchange("/orders/{id}/cancel", HttpMethod.DELETE, HttpEntity(order), Order::class.java, order.getId())
assertThat(invalidCancelMethod.statusCode).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED)
}
@Test
fun `completing an order sets status to completed`() {
val order = Order(emptyList())
orderRepository.save(order)
client.exchange("/orders/{id}/complete", HttpMethod.PUT, HttpEntity(order), Order::class.java, order.getId())
val orderId = order.getId()
val completedOrder = orderRepository.findById(orderId).get()
assertThat(completedOrder.getStatus()).isEqualTo(Status.COMPLETED)
}
//...
}
Almost all of my tests except those who are only using the JpaRepository or those who only use GET request from the RestTestTemplate get the following error:
Error while extracting response for type [class project.github.backend.order.Order] and content type [application/json]
org.springframework.web.client.RestClientException: Error while extracting response for type [class project.github.backend.order.Order] and content type [application/json]
at app//org.springframework.web.client.HttpMessageConverterExtractor.extractData(HttpMessageConverterExtractor.java:118)
at app//org.springframework.web.client.RestTemplate$ResponseEntityResponseExtractor.extractData(RestTemplate.java:1132)
at app//org.springframework.web.client.RestTemplate$ResponseEntityResponseExtractor.extractData(RestTemplate.java:1115)
at app//org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:865)
at app//org.springframework.web.client.RestTemplate.execute(RestTemplate.java:764)
at app//org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:646)
at app//org.springframework.boot.test.web.client.TestRestTemplate.exchange(TestRestTemplate.java:711)
at app//project.github.backend.OrderControllerTest.deleteCancelOrderForEntity(OrderControllerTest.kt:184)
at app//project.github.backend.OrderControllerTest.cancelling an already cancelled order returns 405 method not allowed(OrderControllerTest.kt:88)
...
Caused by: org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `project.github.backend.order.Status` from number 403: index value outside legal index range [0..2]
at app//org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:406)
at app//org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.read(AbstractJackson2HttpMessageConverter.java:354)
at app//org.springframework.web.client.HttpMessageConverterExtractor.extractData(HttpMessageConverterExtractor.java:103)
... 93 more
Caused by: com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `project.github.backend.order.Status` from number 403: index value outside legal index range [0..2]
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 55] (through reference chain: project.github.backend.order.Order["status"])
...
This is the RestController I am testing above with only the relevant functions to the test examples:
import org.springframework.hateoas.CollectionModel
import org.springframework.hateoas.EntityModel
import org.springframework.hateoas.IanaLinkRelations
import org.springframework.hateoas.server.core.DummyInvocationUtils.methodOn
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.stream.Collectors
@RestController
class ProductController(private val repository: ProductRepository, private val assembler: ProductModelAssembler) {
//...
@PutMapping("/products/{id}")
fun replaceProduct(@RequestBody newProduct: Product, @PathVariable id: String): ResponseEntity<*> {
val updatedProduct = findProductById(id)
.map {
updateProductFrom(it, newProduct)
repository.save(it)
}.orElseGet {
newProduct.setId(id)
repository.save(newProduct)
}
val entityModel = assembler.toModel(updatedProduct)
return ResponseEntity
.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(entityModel)
}
private fun updateProductFrom(existingProduct: Product, newProduct: Product) {
existingProduct.setName(newProduct.getName())
existingProduct.setPrice(newProduct.getPrice())
existingProduct.setCurrency(newProduct.getCurrency())
existingProduct.setRebateQuantity(newProduct.getRebateQuantity())
existingProduct.setRebatePercent(newProduct.getRebatePercent())
existingProduct.setUpsellProduct(newProduct.getUpsellProduct())
}
@DeleteMapping("/products/{id}")
fun deleteProduct(@PathVariable id: String): ResponseEntity<*> {
this.repository.deleteById(id)
return ResponseEntity.noContent().build<Any>()
}
}
I have omitted my model assembler, let me know if it provides more context, if so I will add it.
Finally, this is my Order entity and here is the enum that the error references:
enum class Status {
IN_PROGRESS, COMPLETED, CANCELLED
}
@Entity
@Table(name = "CUSTOMER_ORDER")
class Order(
@ManyToMany private var products: List<Product> = emptyList()
) {
private var status: Status = Status.IN_PROGRESS
@Id
@GeneratedValue
private var id: Long? = null
//...
fun getId(): Long = this.id?: throw IllegalStateException("ID has not been set yet")
fun getStatus(): Status = this.status
fun getProducts(): List<Product> = this.products
fun setStatus(status: Status) {
this.status = status
}
}
I believe I have tried every single security configuration both in the Security Configuration and the @BeforeEach setup in my test.
I have tried to append the TestRestTemplate with withBasicAuth() and a username and password defined in the application.properties
I am sure it is something to do with the transfer from HTTP to HTTPS because when I remove the dependency, reconfigure the application.properties to not use SSL and remove the Security Configuration everything works and all tests pass.
It is curious to me that the error attempts the index 403 in the enum in range 0..2. ...Cannot deserialize value of type project.github.backend.order.Status from number 403: index value outside legal index range [0..2]...
This leads me to believe I am encountering a Forbidden response, but I have no clue as to what is causing this.