diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/base/FailModificationApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/base/FailModificationApplication.kt new file mode 100644 index 0000000000..1ebc3143e5 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/base/FailModificationApplication.kt @@ -0,0 +1,148 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.base + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping(path = ["/api/resources"]) +@RestController +open class FailModificationApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(FailModificationApplication::class.java, *args) + } + + private val data = mutableMapOf() + private val dataAlreadyExists = mutableMapOf() + + fun reset(){ + data.clear() + dataAlreadyExists.clear() + dataAlreadyExists[0] = ResourceData("existing", 42) + } + } + + data class ResourceData( + var name: String, + var value: Int + ) + + data class UpdateRequest( + val name: String, + val value: Int + ) + + + @PostMapping(path = ["/empty"]) + open fun create(@RequestBody body: ResourceData): ResponseEntity { + val id = data.size + 1 + data[id] = body.copy() + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping(path = ["/empty/{id}"]) + open fun get(@PathVariable("id") id: Int): ResponseEntity { + val resource = data[id] + ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PutMapping(path = ["/empty/{id}"]) + open fun put( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + + val resource = data[id] + ?: return ResponseEntity.status(404).build() + + // bug: modifies data even though it will return 4xx + if(body.name != null) { + resource.name = body.name + } + if(body.value != null) { + resource.value = body.value + } + + // returns 400 Bad Request, but the data was already modified above + return ResponseEntity.status(400).body("Invalid request") + } + + @PatchMapping(path = ["/empty/{id}"]) + open fun patch( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + + val resource = data[id] + ?: return ResponseEntity.status(404).build() + + // correct: validation first, reject without modifying + if(body.name == null && body.value == null) { + return ResponseEntity.status(400).body("No fields to update") + } + + // correct: does NOT modify data, just returns 4xx + return ResponseEntity.status(403).body("Forbidden") + } + + // pre-populated resource to test that it is not modified by failed PUT + + @PostMapping(path = ["/notempty"]) + open fun createnotempty(@RequestBody body: ResourceData): ResponseEntity { + val id = dataAlreadyExists.size + 1 + data[id] = body.copy() + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping(path = ["/notempty/{id}"]) + open fun getnotempty(@PathVariable("id") id: Int): ResponseEntity { + val resource = dataAlreadyExists[id] + ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PutMapping(path = ["/notempty/{id}"]) + open fun putnotempty( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + + val resource = dataAlreadyExists[id] + ?: return ResponseEntity.status(404).build() + + resource.name = body.name + resource.value = body.value + + // returns 400 Bad Request, but the data was already modified above + return ResponseEntity.status(400).body("Invalid request") + } + + @PatchMapping(path = ["/notempty/{id}"]) + open fun patchnotempty( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + + val resource = dataAlreadyExists[id] + ?: return ResponseEntity.status(404).build() + + // correct: validation first, reject without modifying + return ResponseEntity.status(400).body("No fields to update") + + // correct: does NOT modify data, just returns 4xx + return ResponseEntity.status(403).body("Forbidden") + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/forbidden/FailModificationForbiddenApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/forbidden/FailModificationForbiddenApplication.kt new file mode 100644 index 0000000000..e7b7acb821 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/forbidden/FailModificationForbiddenApplication.kt @@ -0,0 +1,78 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.forbidden + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping("/api/resources") +@RestController +open class FailModificationForbiddenApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(FailModificationForbiddenApplication::class.java, *args) + } + + val USERS = setOf("FOO", "BAR") + + private val data = mutableMapOf() + + fun reset() { + data.clear() + } + } + + data class ResourceData( + val name: String, + var value: String + ) + + data class UpdateRequest( + val value: String + ) + + private fun isValidUser(auth: String?) = auth != null && USERS.contains(auth) + + @PostMapping + open fun create( + @RequestHeader(value = "Authorization", required = false) auth: String?, + @RequestBody body: UpdateRequest + ): ResponseEntity { + if (!isValidUser(auth)) return ResponseEntity.status(401).build() + val id = data.size + 1 + data[id] = ResourceData(name = auth!!, value = body.value) + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping("/{id}") + open fun get( + @RequestHeader(value = "Authorization", required = false) auth: String?, + @PathVariable("id") id: Int + ): ResponseEntity { + if (!isValidUser(auth)) return ResponseEntity.status(401).build() + val resource = data[id] ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PatchMapping("/{id}") + open fun patch( + @RequestHeader(value = "Authorization", required = false) auth: String?, + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + if (!isValidUser(auth)) return ResponseEntity.status(401).build() + + val resource = data[id] ?: return ResponseEntity.status(404).build() + + // BUG: side-effect before ownership check + resource.value = body.value + + if (resource.name != auth) return ResponseEntity.status(403).build() + return ResponseEntity.status(200).build() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/notfound/FailModificationNotFoundApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/notfound/FailModificationNotFoundApplication.kt new file mode 100644 index 0000000000..c355556243 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/notfound/FailModificationNotFoundApplication.kt @@ -0,0 +1,52 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.notfound + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping("/api/resources") +@RestController +open class FailModificationNotFoundApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(FailModificationNotFoundApplication::class.java, *args) + } + + private val data = mutableMapOf() + + fun reset() { + data.clear() + } + } + + data class ResourceData(val name: String, val value: Int) + + data class UpdateRequest(val name: String, val value: Int) + + + @GetMapping("/{id}") + open fun get(@PathVariable("id") id: Int): ResponseEntity { + val resource = data[id] ?: return ResponseEntity.status(404).build() + return ResponseEntity.ok(resource) + } + + @PutMapping("/{id}") + open fun put( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + if (!data.containsKey(id)) { + // BUG: stores the resource before returning 404 + data[id] = ResourceData(body.name, body.value) + return ResponseEntity.status(404).build() + } + data[id] = ResourceData(body.name, body.value) + return ResponseEntity.ok().build() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/PartialUpdatePutApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/PartialUpdatePutApplication.kt new file mode 100644 index 0000000000..a36c5607f3 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/PartialUpdatePutApplication.kt @@ -0,0 +1,74 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping(path = ["/api/resources"]) +@RestController +open class PartialUpdatePutApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(PartialUpdatePutApplication::class.java, *args) + } + + private val data = mutableMapOf() + + fun reset(){ + data.clear() + } + } + + data class ResourceData( + var name: String, + var value: Int + ) + + data class UpdateRequest( + val name: String, + val value: Int + ) + + + @PostMapping() + open fun create(@RequestBody body: ResourceData): ResponseEntity { + val id = data.size + 1 + data[id] = body.copy() + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping(path = ["/{id}"]) + open fun get(@PathVariable("id") id: Int): ResponseEntity { + val resource = data[id] + ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PutMapping(path = ["/{id}"]) + open fun put( + @PathVariable("id") id: Int, + @RequestBody body: UpdateRequest + ): ResponseEntity { + + val resource = data[id] + ?: return ResponseEntity.status(404).build() + + if(body.name != null) { + resource.name = body.name + } + + return ResponseEntity.status(200).body(resource) + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationController.kt new file mode 100644 index 0000000000..d37a1ff075 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationController.kt @@ -0,0 +1,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.base.FailModificationApplication + + +class FailModificationController: SpringController(FailModificationApplication::class.java){ + + override fun resetStateOfSUT() { + FailModificationApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenController.kt new file mode 100644 index 0000000000..9eef90b31c --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenController.kt @@ -0,0 +1,21 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.forbidden.FailModificationForbiddenApplication +import org.evomaster.client.java.controller.AuthUtils +import org.evomaster.client.java.controller.api.dto.auth.AuthenticationDto + + +class FailModificationForbiddenController: SpringController(FailModificationForbiddenApplication::class.java){ + + override fun getInfoForAuthentication(): List { + return listOf( + AuthUtils.getForAuthorizationHeader("FOO","FOO"), + AuthUtils.getForAuthorizationHeader("BAR","BAR"), + ) + } + + override fun resetStateOfSUT() { + FailModificationForbiddenApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundController.kt new file mode 100644 index 0000000000..1c3012deaf --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundController.kt @@ -0,0 +1,11 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.notfound.FailModificationNotFoundApplication + + +class FailModificationNotFoundController: SpringController(FailModificationNotFoundApplication::class.java){ + override fun resetStateOfSUT() { + FailModificationNotFoundApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutController.kt new file mode 100644 index 0000000000..cc0b1990fa --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutController.kt @@ -0,0 +1,11 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.SpringController + + +class HttpPartialUpdatePutController: SpringController(PartialUpdatePutApplication::class.java){ + + override fun resetStateOfSUT() { + PartialUpdatePutApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationEMTest.kt new file mode 100644 index 0000000000..f3127a2038 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationEMTest.kt @@ -0,0 +1,46 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.FailModificationController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class FailModificationEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(FailModificationController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "FailedModificationEM", + 2000 + ) { args: MutableList -> + + setOption(args, "schemaOracles", "false") + setOption(args, "httpOracles", "true") + setOption(args, "useExperimentalOracles", "true") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + val faults = DetectedFaultUtils.getDetectedFaults(solution) + + assertEquals(2, faults.size) + assertEquals(ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION, faults.first().category) + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenEMTest.kt new file mode 100644 index 0000000000..bad8c57b13 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationForbiddenEMTest.kt @@ -0,0 +1,46 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.FailModificationForbiddenController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class FailModificationForbiddenEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(FailModificationForbiddenController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "FailedModificationForbiddenEM", + 3000 + ) { args: MutableList -> + + setOption(args, "security", "true") + setOption(args, "schemaOracles", "false") + setOption(args, "httpOracles", "true") + setOption(args, "useExperimentalOracles", "true") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + val faults = DetectedFaultUtils.getDetectedFaults(solution) + + assertEquals(1, faults.size) + assertEquals(ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION, faults.first().category) + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundEMTest.kt new file mode 100644 index 0000000000..c7fbd75d72 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/failmodification/FailModificationNotFoundEMTest.kt @@ -0,0 +1,44 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.failmodification + +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.FailModificationForbiddenController +import com.foo.rest.examples.spring.openapi.v3.httporacle.failmodification.FailModificationNotFoundController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class FailModificationNotFoundEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(FailModificationNotFoundController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "FailedModificationNotFoundEM", + 2000 + ) { args: MutableList -> + + setOption(args, "schemaOracles", "false") + setOption(args, "httpOracles", "true") + setOption(args, "useExperimentalOracles", "true") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + val faultsCategories = DetectedFaultUtils.getDetectedFaultCategories(solution) + assertTrue(ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION in faultsCategories) + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutEMTest.kt new file mode 100644 index 0000000000..fd5d7cf51d --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutEMTest.kt @@ -0,0 +1,48 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.HttpPartialUpdatePutController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class HttpPartialUpdatePutEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HttpPartialUpdatePutController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpPartialUpdatePutEM", + 2000 + ) { args: MutableList -> + + setOption(args, "security", "false") + setOption(args, "schemaOracles", "false") + setOption(args, "httpOracles", "true") + setOption(args, "useExperimentalOracles", "true") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.PUT, 200, "/api/resources/{id}", null) + + val faults = DetectedFaultUtils.getDetectedFaultCategories(solution) + assertEquals(1, faults.size) + assertEquals(ExperimentalFaultCategory.HTTP_PARTIAL_UPDATE_PUT, faults.first()) + } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/Main.kt b/core/src/main/kotlin/org/evomaster/core/Main.kt index c967064558..bc8852e928 100644 --- a/core/src/main/kotlin/org/evomaster/core/Main.kt +++ b/core/src/main/kotlin/org/evomaster/core/Main.kt @@ -262,8 +262,8 @@ class Main { logTimeSearchInfo(injector, config) //apply new phases - solution = phaseHttpOracle(injector, config, epc, solution) solution = phaseSecurity(injector, config, epc, solution) + solution = phaseHttpOracle(injector, config, epc, solution) solution = phaseFlaky(injector, config, epc, solution) epc.startWriteOutput() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt index 734143cc91..afe345a599 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt @@ -37,6 +37,10 @@ enum class ExperimentalFaultCategory( "TODO"), HTTP_REPEATED_CREATE_PUT(914, "Repeated PUT Creates Resource With 201", "repeatedCreatePut", "TODO"), + HTTP_SIDE_EFFECTS_FAILED_MODIFICATION(915, "A failed PUT or PATCH must not change the resource", "sideEffectsFailedModification", + "TODO"), + HTTP_PARTIAL_UPDATE_PUT(916, "The verb PUT makes a full replacement", "partialUpdatePut", + "TODO"), //3xx: GraphQL diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracle.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracle.kt index c934b40862..afeb5b02c5 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracle.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracle.kt @@ -1,10 +1,15 @@ package org.evomaster.core.problem.rest.oracle +import com.google.gson.JsonParser import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.core.problem.rest.data.RestCallAction import org.evomaster.core.problem.rest.data.RestCallResult import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.param.BodyParam import org.evomaster.core.problem.rest.StatusGroup import org.evomaster.core.search.action.ActionResult +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.utils.GeneUtils object HttpSemanticsOracle { @@ -103,4 +108,268 @@ object HttpSemanticsOracle { return NonWorkingDeleteResult(checkingDelete, nonWorking, delete.getName(), actions.size - 2) } -} \ No newline at end of file + + fun hasSideEffectFailedModification(individual: RestIndividual, + actionResults: List + ): Boolean{ + + if(individual.size() < 3){ + return false + } + + val actions = individual.seeMainExecutableActions() + + val before = actions[actions.size - 3] // GET (before state) + val modify = actions[actions.size - 2] // PUT or PATCH (failed modification) + val after = actions[actions.size - 1] // GET (after state) + + // check verbs: GET, PUT|PATCH, GET + if(before.verb != HttpVerb.GET) { + return false + } + if(modify.verb != HttpVerb.PUT && modify.verb != HttpVerb.PATCH) { + return false + } + if(after.verb != HttpVerb.GET) { + return false + } + + // all three must be on the same resolved path + if(!before.usingSameResolvedPath(modify) || !after.usingSameResolvedPath(modify)) { + return false + } + + // the two GETs must use the same auth so the state comparison is meaningful. + if(before.auth.isDifferentFrom(after.auth)) { + return false + } + + val resBefore = actionResults.find { it.sourceLocalId == before.getLocalId() } as RestCallResult? + ?: return false + val resModify = actionResults.find { it.sourceLocalId == modify.getLocalId() } as RestCallResult? + ?: return false + val resAfter = actionResults.find { it.sourceLocalId == after.getLocalId() } as RestCallResult? + ?: return false + + // before GET must be 2xx + if(!StatusGroup.G_2xx.isInGroup(resBefore.getStatusCode())) { + return false + } + + // PUT/PATCH must have failed with 4xx + if(!StatusGroup.G_4xx.isInGroup(resModify.getStatusCode())) { + return false + } + + // after GET must be 2xx + if(!StatusGroup.G_2xx.isInGroup(resAfter.getStatusCode())) { + return false + } + + val bodyBefore = resBefore.getBody() + val bodyModify = extractRequestBody(modify) + val bodyAfter = resAfter.getBody() + + // if both are null/empty, no side-effect detected + if(bodyBefore.isNullOrEmpty() && bodyAfter.isNullOrEmpty()) { + return false + } + + // extract the field names sent in the PUT/PATCH request body + val modifiedFieldNames = extractModifiedFieldNames(modify) + + // if we can identify specific fields, compare only those to avoid false positives from timestamps etc. + if(modifiedFieldNames.isNotEmpty() + && !bodyBefore.isNullOrEmpty() + && !bodyAfter.isNullOrEmpty() + && !bodyModify.isNullOrEmpty()) { + return hasChangedModifiedFields(bodyBefore, bodyAfter, bodyModify, modifiedFieldNames) + } + + return false + } + + private fun extractRequestBody(modify: RestCallAction): String? { + val bodyParam = modify.parameters.find { it is BodyParam } as BodyParam? + ?: return null + return bodyParam.getValueAsPrintableString(mode = GeneUtils.EscapeMode.JSON) + } + + /** + * Checks the special K==404 side-effect pattern: + * + * GET /path → 404 (resource does not exist before the call) + * PUT|PATCH /path → 404 (failed modification - resource still not found) + * GET /path → ??? (should STILL be 404; anything else is a side-effect) + */ + fun hasSideEffectIn404Modification( + individual: RestIndividual, + actionResults: List + ): Boolean { + + if(individual.size() < 3) return false + + val actions = individual.seeMainExecutableActions() + val before = actions[actions.size - 3] // GET (should be 404) + val modify = actions[actions.size - 2] // PUT or PATCH (should be 404) + val after = actions[actions.size - 1] // GET (oracle target) + + if(before.verb != HttpVerb.GET) return false + if(modify.verb != HttpVerb.PUT && modify.verb != HttpVerb.PATCH) return false + if(after.verb != HttpVerb.GET) return false + + if(!before.usingSameResolvedPath(modify) || !after.usingSameResolvedPath(modify)) return false + + val resBefore = actionResults.find { it.sourceLocalId == before.getLocalId() } as RestCallResult? + ?: return false + val resModify = actionResults.find { it.sourceLocalId == modify.getLocalId() } as RestCallResult? + ?: return false + val resAfter = actionResults.find { it.sourceLocalId == after.getLocalId() } as RestCallResult? + ?: return false + + if(resBefore.getStatusCode() != 404) return false + if(resModify.getStatusCode() != 404) return false + + return resAfter.getStatusCode() != 404 + } + + /** + * Checks the PUT full-replacement oracle: + * + * PUT /path → 2xx (successful update/create with body B) + * GET /path → 2xx (must return exactly the fields that were PUT) + * + * Returns true if any field sent in the PUT body has a different value + * in the subsequent GET response (i.e. partial update bug). + */ + fun hasMismatchedPutResponse( + individual: RestIndividual, + actionResults: List + ): Boolean { + + if (individual.size() < 2) return false + + val actions = individual.seeMainExecutableActions() + val put = actions[actions.size - 2] + val get = actions[actions.size - 1] + + if (put.verb != HttpVerb.PUT) return false + if (get.verb != HttpVerb.GET) return false + + if (!put.usingSameResolvedPath(get)) return false + + if (put.auth.isDifferentFrom(get.auth)) return false + + val resPut = actionResults.find { it.sourceLocalId == put.getLocalId() } as RestCallResult? + ?: return false + val resGet = actionResults.find { it.sourceLocalId == get.getLocalId() } as RestCallResult? + ?: return false + + if (!StatusGroup.G_2xx.isInGroup(resPut.getStatusCode())) return false + if (!StatusGroup.G_2xx.isInGroup(resGet.getStatusCode())) return false + + val putBody = extractRequestBody(put) + val getBody = resGet.getBody() + + if (putBody.isNullOrEmpty() || getBody.isNullOrEmpty()) return false + + val fieldNames = extractModifiedFieldNames(put) + if (fieldNames.isEmpty()) return false + + return hasMismatchedPutFields(putBody, getBody, fieldNames) + } + + /** + * Returns true if any field in [fieldNames] has a different value + * between [putBody] (request) and [getBody] (response). + */ + internal fun hasMismatchedPutFields( + putBody: String, + getBody: String, + fieldNames: Set + ): Boolean { + try { + val jsonPut = JsonParser.parseString(putBody) + val jsonGet = JsonParser.parseString(getBody) + + if (!jsonPut.isJsonObject || !jsonGet.isJsonObject) return false + + val objPut = jsonPut.asJsonObject + val objGet = jsonGet.asJsonObject + + for (field in fieldNames) { + val valuePut = objPut.get(field) ?: continue + val valueGet = objGet.get(field) ?: return true // field absent from GET → mismatch + if (valuePut != valueGet) return true + } + + return false + } catch (e: Exception) { + return false + } + } + + /** + * Extract field names from the PUT/PATCH request body. + * These are the fields that the client attempted to modify. + */ + private fun extractModifiedFieldNames(modify: RestCallAction): Set { + + val bodyParam = modify.parameters.find { it is BodyParam } as BodyParam? + ?: return emptySet() + + val gene = bodyParam.primaryGene() + val objectGene = gene.getWrappedGene(ObjectGene::class.java) as ObjectGene? + ?: if (gene is ObjectGene) gene else null + + if(objectGene == null){ + return emptySet() + } + + return objectGene.fields.map { it.name }.toSet() + } + + /** + * Compares only the fields that were sent in the PUT/PATCH request. + * Returns true if any of those fields changed between the before and after GET responses. + * + * NOTE: This only works when the request payload is a JSON object that directly + * matches the resource structure. It does NOT support operation-based payloads + * such as JSON Patch (RFC 6902). + */ + internal fun hasChangedModifiedFields( + bodyBefore: String, + bodyAfter: String, + bodyModify: String, + fieldNames: Set + ): Boolean { + + try { + val jsonBefore = JsonParser.parseString(bodyBefore) + val jsonAfter = JsonParser.parseString(bodyAfter) + val jsonModify = JsonParser.parseString(bodyModify) + + if(!jsonBefore.isJsonObject || !jsonAfter.isJsonObject || !jsonModify.isJsonObject){ + return false + } + + val objBefore = jsonBefore.asJsonObject + val objAfter = jsonAfter.asJsonObject + val objModify = jsonModify.asJsonObject + + for(field in fieldNames){ + val valueBefore = objBefore.get(field) + val valueAfter = objAfter.get(field) + val valueModify = objModify.get(field) + + if(valueBefore != valueAfter && valueModify == valueAfter){ + return true + } + } + + return false + } catch (e: Exception) { + return false + } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/HttpSemanticsService.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/HttpSemanticsService.kt index 762bf3759d..053da3473a 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/HttpSemanticsService.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/HttpSemanticsService.kt @@ -2,11 +2,15 @@ package org.evomaster.core.problem.rest.service import com.google.inject.Inject import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.problem.httpws.auth.HttpWsAuthenticationInfo +import org.evomaster.core.problem.httpws.auth.HttpWsNoAuth import org.evomaster.core.problem.rest.* import org.evomaster.core.problem.rest.builder.RestIndividualSelectorUtils import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.data.RestCallAction +import org.evomaster.core.problem.rest.data.RestCallResult import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.data.RestPath import org.evomaster.core.problem.rest.service.fitness.RestFitness import org.evomaster.core.problem.rest.service.sampler.AbstractRestSampler import org.evomaster.core.search.EvaluatedIndividual @@ -45,6 +49,9 @@ class HttpSemanticsService { @Inject private lateinit var idMapper: IdMapper + @Inject + private lateinit var builder: RestIndividualBuilder + /** * All actions that can be defined from the OpenAPI schema */ @@ -90,6 +97,9 @@ class HttpSemanticsService { // – A repeated followup PUT with 201 on same endpoint should not return 201 (must enforce 200 or 204) putRepeatedCreated() + + sideEffectsOfFailedModification() + partialUpdatePut() } /** @@ -198,4 +208,198 @@ class HttpSemanticsService { } } + + private fun sideEffectsOfFailedModification() { + + val verbs = listOf(HttpVerb.PUT, HttpVerb.PATCH) + + for (verb in verbs) { + + val modifyOperations = RestIndividualSelectorUtils.getAllActionDefinitions(actionDefinitions, verb) + + modifyOperations.forEach { modOp -> + + val getDef = actionDefinitions.find { it.verb == HttpVerb.GET && it.path == modOp.path } + ?: return@forEach + + val failedModifyEvals = RestIndividualSelectorUtils.findIndividuals( + individualsInSolution, + verb, + modOp.path, + statusGroup = StatusGroup.G_4xx + ) + if (failedModifyEvals.isEmpty()) return@forEach + + // gather distinct 4xx status codes observed on this verb+path + val distinctCodes = failedModifyEvals.flatMap { ei -> + ei.evaluatedMainActions().mapNotNull { ea -> + val a = ea.action as? RestCallAction ?: return@mapNotNull null + val r = ea.result as? RestCallResult ?: return@mapNotNull null + if (a.verb == verb && a.path.isEquivalent(modOp.path) + && StatusGroup.G_4xx.isInGroup(r.getStatusCode()) + ) r.getStatusCode() else null + } + }.distinct() + + for (k in distinctCodes) { + when (k) { + 401, 403 -> handle401Or403SideEffect(verb, k, modOp.path) + else -> addGetAroundFailedModification(verb, k, modOp.path, getDef, failedModifyEvals) + } + } + } + } + } + + /** + * Handles K==401 and K==403. + * + * 1. Find T — smallest individual ending with a clean GET 2xx on [path] + * 2. Find a 2xx PUT/PATCH action as the body template or fall back to the K action if no 2xx exists + * 3. Copy the template and override auth: + * K==401 → NoAuth (expected to trigger 401) + * K==403 → a different authenticated user + * 4. Append the modified PUT/PATCH after the GET in T, then append another GET + */ + private fun handle401Or403SideEffect(verb: HttpVerb, k: Int, path: RestPath) { + + // GET schema definition — needed to create the GET after via builder + val getDef = actionDefinitions.find { it.verb == HttpVerb.GET && it.path.isEquivalent(path) } + ?: return + + // T: smallest clean individual ending with GET 2xx (no prior PUT/PATCH on same path) + val T = RestIndividualSelectorUtils.findAndSlice( + individualsInSolution, HttpVerb.GET, path, statusGroup = StatusGroup.G_2xx + ).filter { ind -> + val actions = ind.seeMainExecutableActions() + actions.subList(0, actions.size - 1).none { + (it.verb == HttpVerb.PUT || it.verb == HttpVerb.PATCH) && it.path.isEquivalent(path) + } + }.minByOrNull { it.size() } ?: return + + // find a 2xx PUT/PATCH action to use as the body template + // 401/403 action itself if no 2xx exists + val successAction = RestIndividualSelectorUtils.findIndividuals( + individualsInSolution, verb, path, statusGroup = StatusGroup.G_2xx + ).flatMap { ei -> + ei.evaluatedMainActions().mapNotNull { ea -> + val a = ea.action as? RestCallAction ?: return@mapNotNull null + val r = ea.result as? RestCallResult ?: return@mapNotNull null + if (a.verb == verb && a.path.isEquivalent(path) && StatusGroup.G_2xx.isInGroup(r.getStatusCode())) + a else null + } + }.firstOrNull() + ?: RestIndividualSelectorUtils.findIndividuals( + individualsInSolution, verb, path, status = k + ).flatMap { ei -> + ei.evaluatedMainActions().mapNotNull { ea -> + (ea.action as? RestCallAction) + ?.takeIf { it.verb == verb && it.path.isEquivalent(path) } + } + }.firstOrNull() + ?: return + + val ind = T.copy() as RestIndividual + val getAction = ind.seeMainExecutableActions().last().copy() as RestCallAction // the GET 2xx at the end of T + val successCopy = successAction.copy() as RestCallAction + + successCopy.forceNewTaints() + successCopy.resetLocalIdRecursively() + + + // we override auth afterwards to achieve no-auth (401) or different-user (403) + val modifyCopy = builder.createBoundActionFor(successCopy, getAction) + when (k) { + 401 -> modifyCopy.auth = HttpWsNoAuth() + 403 -> { + val otherAuths = sampler.authentications + .getAllOthers(getAction.auth.name, HttpWsAuthenticationInfo::class.java) + if (otherAuths.isEmpty()) return + modifyCopy.auth = otherAuths.first() + } + } + getAction.forceNewTaints() + getAction.resetLocalIdRecursively() + + val getAfter = builder.createBoundActionFor(getDef, getAction) + + + ind.addMainActionInEmptyEnterpriseGroup(action = modifyCopy) + ind.addMainActionInEmptyEnterpriseGroup(action = getAfter) + + + + ind.ensureFlattenedStructure() + org.evomaster.core.Lazy.assert { ind.verifyValidity(); true } + + prepareEvaluateAndSave(ind) + } + + /** + * Takes the smallest individual in [candidates] where [verb] on [path] returned [k], + * slices it at that action, then inserts a GET immediately before it and appends + * another GET immediately after it — both on the same resolved path and with the + * same auth as the PUT/PATCH: + * + * GET /path (same auth as PUT/PATCH) + * PUT|PATCH /path [k] + * GET /path (same auth) + */ + private fun addGetAroundFailedModification( + verb: HttpVerb, + k: Int, + path: RestPath, + getDef: RestCallAction, + candidates: List> + ) { + val kEval = RestIndividualSelectorUtils.findIndividuals(candidates, verb, path, status = k) + .minByOrNull { it.individual.size() } ?: return + + val ind = RestIndividualBuilder.sliceAllCallsInIndividualAfterAction(kEval, verb, path, status = k) + + val actions = ind.seeMainExecutableActions() + val last = actions.last() // the PUT/PATCH [k] + + // insert GET before the PUT/PATCH + val getBefore = builder.createBoundActionFor(getDef, last) + ind.addMainActionInEmptyEnterpriseGroup(actions.size - 1, getBefore) + + // append GET after the PUT/PATCH + val getAfter = builder.createBoundActionFor(getDef, last) + ind.addMainActionInEmptyEnterpriseGroup(-1, getAfter) + + prepareEvaluateAndSave(ind) + } + + /** + * Checking that PUT makes a full replacement: + * PUT /X with body B → 2xx + * GET /X → response body must contain exactly the fields sent in B + */ + private fun partialUpdatePut() { + + val putOperations = RestIndividualSelectorUtils.getAllActionDefinitions(actionDefinitions, HttpVerb.PUT) + + putOperations.forEach { putOp -> + + val getDef = actionDefinitions.find { it.verb == HttpVerb.GET && it.path == putOp.path } + ?: return@forEach + + val successPuts = RestIndividualSelectorUtils.findIndividuals( + individualsInSolution, HttpVerb.PUT, putOp.path, statusGroup = StatusGroup.G_2xx + ) + if (successPuts.isEmpty()) return@forEach + + val ind = RestIndividualBuilder.sliceAllCallsInIndividualAfterAction( + successPuts.minBy { it.individual.size() }, + HttpVerb.PUT, putOp.path, statusGroup = StatusGroup.G_2xx + ) + + val last = ind.seeMainExecutableActions().last() // the PUT 2xx + val getAfter = builder.createBoundActionFor(getDef, last) + ind.addMainActionInEmptyEnterpriseGroup(-1, getAfter) + + prepareEvaluateAndSave(ind) + } + } } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt index cd95c30020..c799fc91a0 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt @@ -1216,6 +1216,62 @@ abstract class AbstractRestFitness : HttpWsFitness() { } else { handleRepeatedCreatePut(individual, actionResults, fv) } + + if(!config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION)) { + LoggingUtil.uniqueUserInfo("Skipping experimental security test for repeated PUT after CREATE, as it has been disabled via configuration") + } else { + handleFailedModification(individual, actionResults, fv) + } + + if(!config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_PARTIAL_UPDATE_PUT)) { + LoggingUtil.uniqueUserInfo("Skipping experimental test for partial PUT update, as it has been disabled via configuration") + } else { + handlePartialUpdatePut(individual, actionResults, fv) + } + } + + private fun handleFailedModification( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + // covers normal / 401 / 403 cases: GET 2xx → PUT|PATCH 4xx → GET 2xx (fields unchanged) + val hasSideEffect = HttpSemanticsOracle.hasSideEffectFailedModification(individual, actionResults) + // covers the 404 special case: GET 404 → PUT|PATCH 404 → GET (must still be 404) + val hasSideEffect404 = HttpSemanticsOracle.hasSideEffectIn404Modification(individual, actionResults) + + if (!hasSideEffect && !hasSideEffect404) { + return + } + + val putOrPatch = individual.seeMainExecutableActions().filter { + it.verb == HttpVerb.PUT || it.verb == HttpVerb.PATCH + }.last() + + val category = ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION + val scenarioId = idMapper.handleLocalTarget(idMapper.getFaultDescriptiveId(category, putOrPatch.getName())) + fv.updateTarget(scenarioId, 1.0, individual.seeMainExecutableActions().lastIndex) + + val ar = actionResults.find { it.sourceLocalId == putOrPatch.getLocalId() } as RestCallResult? + ?: return + ar.addFault(DetectedFault(category, putOrPatch.getName(), null)) + } + + private fun handlePartialUpdatePut( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!HttpSemanticsOracle.hasMismatchedPutResponse(individual, actionResults)) return + + val put = individual.seeMainExecutableActions().filter { it.verb == HttpVerb.PUT }.last() + + val category = ExperimentalFaultCategory.HTTP_PARTIAL_UPDATE_PUT + val scenarioId = idMapper.handleLocalTarget(idMapper.getFaultDescriptiveId(category, put.getName())) + fv.updateTarget(scenarioId, 1.0, individual.seeMainExecutableActions().lastIndex) + + val ar = actionResults.find { it.sourceLocalId == put.getLocalId() } as RestCallResult? ?: return + ar.addFault(DetectedFault(category, put.getName(), null)) } private fun handleRepeatedCreatePut( diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracleTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracleTest.kt new file mode 100644 index 0000000000..23a86473e7 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/oracle/HttpSemanticsOracleTest.kt @@ -0,0 +1,108 @@ +package org.evomaster.core.problem.rest.oracle + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class HttpSemanticsOracleTest { + + @Test + fun testUnchangedModifiedFieldReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"name":"Doe","ts":"2026-01-01"}""", + bodyAfter = """{"name":"Doe","ts":"2026-01-02"}""", + bodyModify = """{"name":"Test"}""", + fieldNames = setOf("name") + )) + } + + @Test + fun testNoModifiedFieldChangedReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"name":"Doe","email":"a@a.com","age":30}""", + bodyAfter = """{"name":"Doe","email":"a@a.com","age":31}""", + bodyModify = """{"age":31}""", + fieldNames = setOf("name", "email") + )) + } + + @Test + fun testModifiedFieldAbsentInBothBodiesReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"age":30}""", + bodyAfter = """{"age":31}""", + bodyModify = """{"age":31}""", + fieldNames = setOf("name") + )) + } + + @Test + fun testUnchangedIntegerModifiedFieldReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"count":42,"label":"test"}""", + bodyAfter = """{"count":42,"label":"changed"}""", + bodyModify = """{"count":42,"label":"changed"}""", + fieldNames = setOf("count") + )) + } + + // hasChangedModifiedFields — field changed -> true + @Test + fun testChangedModifiedFieldReturnsTrue() { + assertTrue(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"name":"Doe","age":42}""", + bodyAfter = """{"name":"Bob","age":42}""", + bodyModify = """{"name":"Bob"}""", + fieldNames = setOf("name") + )) + } + + @Test + fun testOneOfMultipleModifiedFieldsChangedReturnsTrue() { + assertTrue(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"name":"Doe","email":"a@a.com","age":42}""", + bodyAfter = """{"name":"Doe","email":"b@b.com","age":42}""", + bodyModify = """{"name":"Doe","email":"b@b.com","age":42}""", + fieldNames = setOf("name", "email") + )) + } + + @Test + fun testModifiedFieldPresentInBeforeButAbsentInAfterReturnsTrue() { + assertTrue(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"name":"Doe"}""", + bodyAfter = """{"age":42}""", + bodyModify = """{"age":42}""", + fieldNames = setOf("name") + )) + } + + @Test + fun testChangedIntegerModifiedFieldReturnsTrue() { + assertTrue(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = """{"count":42,"label":"test"}""", + bodyAfter = """{"count":44,"label":"test"}""", + bodyModify = """{"count":44,"label":"test"}""", + fieldNames = setOf("count") + )) + } + + @Test + fun testInvalidJsonDifferentBodiesFallbackReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = "not valid json", + bodyAfter = "also not valid json", + bodyModify = "{}", + fieldNames = setOf("name") + )) + } + + @Test + fun testInvalidJsonSameBodiesFallbackReturnsFalse() { + assertFalse(HttpSemanticsOracle.hasChangedModifiedFields( + bodyBefore = "not valid json", + bodyAfter = "not valid json", + bodyModify = "{}", + fieldNames = setOf("name") + )) + } +}