diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/json/PartialUpdatePutApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/json/PartialUpdatePutApplication.kt new file mode 100644 index 0000000000..4ee78676e5 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/json/PartialUpdatePutApplication.kt @@ -0,0 +1,73 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.json + +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.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) + } +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/urlencoded/PartialUpdatePutUrlEncodedApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/urlencoded/PartialUpdatePutUrlEncodedApplication.kt new file mode 100644 index 0000000000..b2b0690f0d --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/urlencoded/PartialUpdatePutUrlEncodedApplication.kt @@ -0,0 +1,84 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.urlencoded + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +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.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping(path = ["/api/resources"]) +@RestController +open class PartialUpdatePutUrlEncodedApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(PartialUpdatePutUrlEncodedApplication::class.java, *args) + } + + private val data = mutableMapOf() + + fun reset(){ + data.clear() + } + } + + open class ResourceData( + var name: String = "", + var value: Int = 0 + ) + + open class UpdateRequest( + var name: String = "", + var value: Int = 0 + ) + + + @PostMapping( + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun create(@ModelAttribute body: ResourceData): ResponseEntity { + val id = data.size + 1 + data[id] = ResourceData(name = body.name, value = body.value) + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping( + path = ["/{id}"], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + 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}"], + consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE], + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + open fun put( + @PathVariable("id") id: Int, + @ModelAttribute 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) + } +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/xml/PartialUpdatePutXMLApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/xml/PartialUpdatePutXMLApplication.kt new file mode 100644 index 0000000000..44e6d60b8c --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/xml/PartialUpdatePutXMLApplication.kt @@ -0,0 +1,91 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.xml + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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 +import javax.xml.bind.annotation.XmlAccessType +import javax.xml.bind.annotation.XmlAccessorType +import javax.xml.bind.annotation.XmlRootElement + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping(path = ["/api/resources"]) +@RestController +open class PartialUpdatePutXMLApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(PartialUpdatePutXMLApplication::class.java, *args) + } + + private val data = mutableMapOf() + + fun reset(){ + data.clear() + } + } + + @XmlRootElement(name = "resourceData") + @XmlAccessorType(XmlAccessType.FIELD) + open class ResourceData( + var name: String = "", + var value: Int = 0 + ) + + @XmlRootElement(name = "updateRequest") + @XmlAccessorType(XmlAccessType.FIELD) + open class UpdateRequest( + var name: String = "", + var value: Int = 0 + ) + + + @PostMapping( + consumes = [MediaType.APPLICATION_XML_VALUE], + produces = [MediaType.APPLICATION_XML_VALUE] + ) + open fun create(@RequestBody body: ResourceData): ResponseEntity { + val id = data.size + 1 + data[id] = ResourceData(name = body.name, value = body.value) + return ResponseEntity.status(201).body(data[id]) + } + + @GetMapping( + path = ["/{id}"], + produces = [MediaType.APPLICATION_XML_VALUE] + ) + 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}"], + consumes = [MediaType.APPLICATION_XML_VALUE], + produces = [MediaType.APPLICATION_XML_VALUE] + ) + 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) + } +} \ No newline at end of file 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..63dee52c84 --- /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,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.json.PartialUpdatePutApplication + + +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/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedController.kt new file mode 100644 index 0000000000..b2b1e03711 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedController.kt @@ -0,0 +1,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.urlencoded.PartialUpdatePutUrlEncodedApplication + + +class HttpPartialUpdatePutURLEncodedController: SpringController(PartialUpdatePutUrlEncodedApplication::class.java){ + + override fun resetStateOfSUT() { + PartialUpdatePutUrlEncodedApplication.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/HttpPartialUpdatePutXMLController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutXMLController.kt new file mode 100644 index 0000000000..98fc17ce8f --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutXMLController.kt @@ -0,0 +1,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.xml.PartialUpdatePutXMLApplication + + +class HttpPartialUpdatePutXMLController: SpringController(PartialUpdatePutXMLApplication::class.java){ + + override fun resetStateOfSUT() { + PartialUpdatePutXMLApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/delete/HttpOracleDeleteEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/delete/HttpOracleDeleteEMTest.kt index d6c655efb0..c122203cd8 100644 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/delete/HttpOracleDeleteEMTest.kt +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/delete/HttpOracleDeleteEMTest.kt @@ -1,6 +1,7 @@ package org.evomaster.e2etests.spring.openapi.v3.httporacle.delete import com.foo.rest.examples.spring.openapi.v3.httporacle.delete.HttpOracleDeleteController +import com.webfuzzing.commons.faults.DefinedFaultCategory import com.webfuzzing.commons.faults.FaultCategory import org.evomaster.core.problem.enterprise.DetectedFaultUtils import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory @@ -47,8 +48,8 @@ class HttpOracleDeleteEMTest : SpringTestBase(){ val faults = DetectedFaultUtils.getDetectedFaultCategories(solution) - assertEquals(1, faults.size) - assertEquals(ExperimentalFaultCategory.HTTP_NONWORKING_DELETE, faults.first()) + assertTrue({ ExperimentalFaultCategory.HTTP_NONWORKING_DELETE in faults }) + } } -} \ No newline at end of file +} 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..aca3e5a24d --- /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", + 1000 + ) { 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-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedEMTest.kt new file mode 100644 index 0000000000..d7b2e28435 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutURLEncodedEMTest.kt @@ -0,0 +1,48 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.HttpPartialUpdatePutURLEncodedController +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 HttpPartialUpdatePutURLEncodedEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HttpPartialUpdatePutURLEncodedController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpPartialUpdatePutURLEncodedEM", + 1000 + ) { 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-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutXMLEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutXMLEMTest.kt new file mode 100644 index 0000000000..9e5f0cb43d --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/partialupdateput/HttpPartialUpdatePutXMLEMTest.kt @@ -0,0 +1,48 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.partialupdateput + +import com.foo.rest.examples.spring.openapi.v3.httporacle.partialupdateput.HttpPartialUpdatePutXMLController +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 HttpPartialUpdatePutXMLEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HttpPartialUpdatePutXMLController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpPartialUpdatePutXMLEM", + 1000 + ) { 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/output/formatter/OutputFormatter.kt b/core/src/main/kotlin/org/evomaster/core/output/formatter/OutputFormatter.kt index 5e8d00a8eb..f2b6504eaa 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/formatter/OutputFormatter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/formatter/OutputFormatter.kt @@ -74,6 +74,9 @@ abstract class OutputFormatter (val name: String) { if (!node.isObject) return null fieldNames.mapNotNull { field -> val value = node.get(field) ?: return@mapNotNull null + // JSON null is reported as field-absent so callers cannot confuse + // it with the literal 4-char string "null" (asText() collapses both). + if (value.isNull) return@mapNotNull null field to value.asText() }.toMap() } catch (e: Exception) { 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 7630926ccb..88b6bfb910 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 @@ -30,6 +30,8 @@ enum class ExperimentalFaultCategory( "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"), HTTP_STATUS_NO_NON_STANDARD_CODES(950, "no-non-standard-codes", "invalidStatusCode", "TODO"), 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 d4630dc56d..8ac31ddb42 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 @@ -6,10 +6,13 @@ 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.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaUtils 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 +import org.evomaster.core.search.gene.wrapper.OptionalGene object HttpSemanticsOracle { @@ -247,24 +250,227 @@ object HttpSemanticsOracle { return resAfter.getStatusCode() != 404 } + /** + * Checks the PUT full-replacement oracle. + * + * Sequence: + * PUT /path -> 2xx (full replacement with body B) + * GET /path -> 2xx (must reflect B exactly, excluding fields outside the PUT schema) + * + * Two independent checks are performed: + * + * 1. Sent fields (fields actually included in the PUT body) must come back + * in the GET with the same value. Differences are bugs (partial update). + * + * 2. Wiped fields (fields that are part of the PUT schema but were not + * included in this request) must be absent or null in the GET. PUT is a + * full replacement, so the server should have cleared them. Any leftover + * value is also a partial-update bug. + * + * Fields that exist in the GET response schema but are NOT in the PUT schema + * (e.g. server-managed `id`, `createdAt`) are ignored: PUT cannot control them. + + */ + fun hasMismatchedPutResponse( + individual: RestIndividual, + actionResults: List, + schema: RestSchema? = null + ): Boolean { + + val (put, get, resPut, resGet) = findPutGetPair(individual, actionResults) ?: return false + + if (!StatusGroup.G_2xx.isInGroup(resPut.getStatusCode())) return false + // if put returned 2xx but entity does not exist afterwards + if (resGet.getStatusCode() == 404) return true + if (!StatusGroup.G_2xx.isInGroup(resGet.getStatusCode())) return false + + val bodyParam = put.parameters.find { it is BodyParam } as BodyParam? + + //for now we only deal with JSON/XML/FORM + if (bodyParam != null && !bodyParam.isJson() && !bodyParam.isXml() && !bodyParam.isForm()) { + return false + } + + val putBody = extractRequestBody(put) + val getBody = resGet.getBody() + + // PUT sent content but GET body is empty -> sent fields definitely missing + if (getBody.isNullOrEmpty()) { + return !putBody.isNullOrEmpty() + } + + + val sentFields = extractSentFieldNames(put) + val allPutSchemaFields = extractModifiedFieldNames(put).ifEmpty { + schema?.let { SchemaUtils.extractRequestBodySchemaFields(it, put.path.toString(), HttpVerb.PUT) } ?: emptySet() + } + if (sentFields.isEmpty() && allPutSchemaFields.isEmpty()) { + // no information to verify against; flag only when PUT sent nothing either + return putBody.isNullOrEmpty() + } + + val wipedFields = computeWipedFields(allPutSchemaFields - sentFields, schema, get) + + return hasMismatchedPutFields(putBody ?: "", getBody, sentFields, wipedFields, bodyParam) + } + + private data class PutGetPair( + val put: RestCallAction, + val get: RestCallAction, + val resPut: RestCallResult, + val resGet: RestCallResult + ) + + /** + * Validates and extracts the trailing PUT/GET pair from the individual. + * Returns null if any structural or authorization precondition fails. + */ + private fun findPutGetPair( + individual: RestIndividual, + actionResults: List + ): PutGetPair? { + if (individual.size() < 2) return null + + val actions = individual.seeMainExecutableActions() + val put = actions[actions.size - 2] + val get = actions[actions.size - 1] + + if (put.verb != HttpVerb.PUT) return null + if (get.verb != HttpVerb.GET) return null + if (!put.usingSameResolvedPath(get)) return null + if (put.auth.isDifferentFrom(get.auth)) return null + + val resPut = actionResults.find { it.sourceLocalId == put.getLocalId() } as RestCallResult? + ?: return null + val resGet = actionResults.find { it.sourceLocalId == get.getLocalId() } as RestCallResult? + ?: return null + + return PutGetPair(put, get, resPut, resGet) + } + + /** + * Wiped candidates are restricted to fields the GET schema actually exposes, otherwise + * write-only fields (e.g. passwords) would cause false positives. + */ + private fun computeWipedFields( + candidates: Set, + schema: RestSchema?, + get: RestCallAction + ): Set { + if (candidates.isEmpty() || schema == null) return emptySet() + val getSchemaFields = SchemaUtils.extractResponseSchemaFields( + schema, get.path.toString(), HttpVerb.GET, + statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_2xx) + ) + if (getSchemaFields.isEmpty()) return emptySet() + return candidates intersect getSchemaFields + } + + /** + * Unified field-level comparison for JSON, XML and form-encoded PUT bodies. + * + * @param sentFields fields whose values must match between PUT and GET + * @param wipedFields fields that must be absent (or null) in the GET response + */ + internal fun hasMismatchedPutFields( + putBody: String, + getBody: String, + sentFields: Set, + wipedFields: Set, + bodyParam: BodyParam? = null + ): Boolean { + + // sent fields: PUT value must equal GET value + if (sentFields.isNotEmpty()) { + val fieldsPut = readPutFields(putBody, bodyParam, sentFields) ?: return false + if (fieldsPut.isNotEmpty()) { + val fieldsGet = readGetFields(getBody, fieldsPut.keys) ?: return true + for ((field, valuePut) in fieldsPut) { + val valueGet = fieldsGet[field] ?: return true + if (valuePut != valueGet) return true + } + } + } + + // wiped fields: must be absent or null in GET + if (wipedFields.isNotEmpty()) { + val getWiped = readGetFields(getBody, wipedFields) ?: return false + for (field in wipedFields) { + if (!getWiped[field].isNullOrEmpty()) return true + } + } + + return false + } + + /** + * Extracts field values from a PUT request body according to its content type. + * Returns null if the body cannot be parsed by the chosen reader. + */ + private fun readPutFields( + putBody: String, + bodyParam: BodyParam?, + fieldNames: Set + ): Map? = when { + bodyParam == null || bodyParam.isJson() -> + OutputFormatter.JSON_FORMATTER.readFields(putBody, fieldNames) + bodyParam.isXml() -> + OutputFormatter.XML_FORMATTER.readFields(putBody, fieldNames) + bodyParam.isForm() -> { + val parsed = parseFormBody(putBody) + if (parsed.isEmpty()) null + else fieldNames.mapNotNull { f -> parsed[f]?.let { f to it } }.toMap() + } + else -> null + } + + /** + * Extracts field values from a GET response body, auto-detecting JSON or XML. + * Returns null if neither formatter can parse the body. + */ + private fun readGetFields( + getBody: String, + fieldNames: Set + ): Map? { + return OutputFormatter.JSON_FORMATTER.readFields(getBody, fieldNames) + ?: OutputFormatter.XML_FORMATTER.readFields(getBody, fieldNames) + } + /** * 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 objectGene = extractBodyObjectGene(modify) ?: return emptySet() + + return objectGene.fields.map { it.name }.toSet() + } + + /** + * Extract only the field names that were actually sent in the request body. + */ + private fun extractSentFieldNames(modify: RestCallAction): Set { + + val objectGene = extractBodyObjectGene(modify) ?: return emptySet() + + return objectGene.fields + .filter { f -> (f as? OptionalGene)?.isActive ?: true } + .map { it.name } + .toSet() + } + + private fun extractBodyObjectGene(modify: RestCallAction): ObjectGene? { val bodyParam = modify.parameters.find { it is BodyParam } as BodyParam? - ?: return emptySet() + ?: return null 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() - } + // an optional body that is not active means nothing is sent, so there + // are no fields to extract regardless of the underlying ObjectGene. + if (gene is OptionalGene && !gene.isActive) return null - return objectGene.fields.map { it.name }.toSet() + return gene.getWrappedGene(ObjectGene::class.java) as ObjectGene? + ?: if (gene is ObjectGene) gene else null } /** diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtils.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtils.kt index 98c1f091af..13738a549f 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtils.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtils.kt @@ -1,6 +1,7 @@ package org.evomaster.core.problem.rest.schema +import io.swagger.v3.oas.models.Operation import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.examples.Example import io.swagger.v3.oas.models.links.Link @@ -9,6 +10,8 @@ import io.swagger.v3.oas.models.parameters.Parameter import io.swagger.v3.oas.models.parameters.RequestBody import io.swagger.v3.oas.models.responses.ApiResponse import org.evomaster.core.logging.LoggingUtil +import org.evomaster.core.problem.rest.StatusGroup +import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.data.Endpoint import java.net.URI import java.net.URISyntaxException @@ -293,6 +296,114 @@ object SchemaUtils { return response } + /** + * Recursively collect property names from a schema, resolving `$ref` at every level + * and merging the union of properties across `allOf` parts. + * + * `oneOf` / `anyOf` are intentionally ignored: their semantics are alternatives, not + * a stable set of writable fields, so including them would produce false positives + * in callers that compare PUT-sent fields to GET-returned ones. + * + * Cycle protection: a `$ref` already on the resolution stack is skipped, which is + * safe for property-name collection (the names are union-merged into a Set). + * + * Looks at the original schema (not at the parsed [RestCallAction] gene tree) because + * taint analysis can rewrite the action's gene tree, while the spec is the source of truth. + */ + fun collectPropertyNames( + schema: RestSchema, + raw: Schema<*>?, + visitedRefs: MutableSet = mutableSetOf() + ): Set { + if (raw == null) return emptySet() + + val ref = raw.`$ref` + val resolved: Schema<*>? = if (ref != null) { + if (!visitedRefs.add(ref)) return emptySet() + getReferenceSchema(schema, schema.main, ref, mutableListOf()) + } else raw + + if (resolved == null) return emptySet() + + val result = mutableSetOf() + resolved.properties?.keys?.let { result.addAll(it) } + resolved.allOf?.forEach { result.addAll(collectPropertyNames(schema, it, visitedRefs)) } + return result + } + + /** + * Returns the operation associated with [verb] in the given [pathItem], or null if absent. + */ + private fun pathItemOperation(pathItem: PathItem, verb: HttpVerb): Operation? = when (verb) { + HttpVerb.GET -> pathItem.get + HttpVerb.POST -> pathItem.post + HttpVerb.PUT -> pathItem.put + HttpVerb.DELETE -> pathItem.delete + HttpVerb.OPTIONS -> pathItem.options + HttpVerb.PATCH -> pathItem.patch + HttpVerb.HEAD -> pathItem.head + HttpVerb.TRACE -> pathItem.trace + } + + /** + * Status-key matchers for use with [extractResponseSchemaFields]. + * Each takes the raw OpenAPI response key (e.g. "200", "2XX", "default") and decides match. + */ + fun statusGroupMatcher(group: StatusGroup): (String) -> Boolean = { key -> + key.toIntOrNull()?.let(group::isInGroup) == true + } + + fun statusCodeMatcher(code: Int): (String) -> Boolean = { it == code.toString() } + + fun statusCodesMatcher(vararg codes: Int): (String) -> Boolean { + val set = codes.map(Int::toString).toSet() + return { key -> key in set } + } + + /** + * Returns the property names from the request body schema for the given path + verb. + * Used as a fallback to determine writable fields when no BodyParam is present on the action. + */ + fun extractRequestBodySchemaFields( + schema: RestSchema, + pathString: String, + verb: HttpVerb + ): Set { + val openAPI = schema.main.schemaParsed + val pathItem = openAPI.paths?.get(pathString) ?: return emptySet() + val op = pathItemOperation(pathItem, verb) ?: return emptySet() + val requestBody = op.requestBody ?: return emptySet() + val mediaType = requestBody.content?.values?.firstOrNull() ?: return emptySet() + return collectPropertyNames(schema, mediaType.schema) + } + + /** + * Returns the property names from a response schema for the given path + verb. + * The response is selected by [statusMatcher] (defaults to first 2xx); if no response + * matches and [fallbackToDefault] is true, "default" is used. + * + * Convenience matchers: [statusGroupMatcher], [statusCodeMatcher], [statusCodesMatcher]. + */ + fun extractResponseSchemaFields( + schema: RestSchema, + pathString: String, + verb: HttpVerb, + statusMatcher: (String) -> Boolean = statusGroupMatcher(StatusGroup.G_2xx), + fallbackToDefault: Boolean = true + ): Set { + val openAPI = schema.main.schemaParsed + val pathItem = openAPI.paths?.get(pathString) ?: return emptySet() + val op = pathItemOperation(pathItem, verb) ?: return emptySet() + + val response = op.responses?.entries?.firstOrNull { statusMatcher(it.key) }?.value + ?: (if (fallbackToDefault) op.responses?.get("default") else null) + ?: return emptySet() + + val mediaType = response.content?.values?.firstOrNull() ?: return emptySet() + return collectPropertyNames(schema, mediaType.schema) + } + + /** * depending on whether path is a file or folder, return a list with 1 or more Overlays. 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 01ff8f7c7f..876a622e64 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 @@ -113,6 +113,7 @@ class HttpSemanticsService : TimeBoxedPhase{ if(hasPhaseTimedOut()) return sideEffectsOfFailedModification() + partialUpdatePut() } /** @@ -392,5 +393,42 @@ class HttpSemanticsService : TimeBoxedPhase{ prepareEvaluateAndSave(ind) } + /** + * HTTP_PARTIAL_UPDATE_PUT oracle: PUT makes a full replacement, not a partial update. + * If only some fields should be modified, PATCH must be used instead. + * + * Sequence checked: + * PUT /X body=B -> 2xx + * GET /X -> response body must match exactly B + * (no field from a previous state should bleed through) + * + * Finds the shortest 2xx PUT individual, slices it to end at that PUT, + * then appends a bound GET on the same resolved path to verify the full replacement. + */ + 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 e0306c5d9f..c6a7a75fc7 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 @@ -1255,10 +1255,14 @@ abstract class AbstractRestFitness : HttpWsFitness() { if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_REPEATED_CREATE_PUT)) { handleRepeatedCreatePut(individual, actionResults, fv) } - + if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_SIDE_EFFECTS_FAILED_MODIFICATION)) { handleFailedModification(individual, actionResults, fv) } + + if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_PARTIAL_UPDATE_PUT)) { + handlePartialUpdatePut(individual, actionResults, fv) + } } private fun handleFailedModification( @@ -1338,6 +1342,23 @@ abstract class AbstractRestFitness : HttpWsFitness() { } } + private fun handlePartialUpdatePut( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + val schemaHolder = (sampler as AbstractRestSampler).schemaHolder + if (!HttpSemanticsOracle.hasMismatchedPutResponse(individual, actionResults, schemaHolder)) 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)) + } protected fun recordResponseData(individual: RestIndividual, actionResults: List) { 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 index 5af0801637..4f7fa87a73 100644 --- 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 @@ -1,8 +1,20 @@ package org.evomaster.core.problem.rest.oracle +import org.evomaster.core.problem.enterprise.SampleType +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.param.BodyParam +import org.evomaster.core.problem.rest.schema.OpenApiAccess +import org.evomaster.core.problem.rest.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaLocation import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.ObjectGene import org.evomaster.core.search.gene.collection.EnumGene +import org.evomaster.core.search.gene.string.StringGene +import org.evomaster.core.search.gene.wrapper.OptionalGene import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -347,4 +359,464 @@ class HttpSemanticsOracleTest { bodyParam = formBodyParam() )) } + + private fun jsonPutBodyParam( + activeFields: Map, + omittedFields: Set = emptySet() + ): BodyParam { + val fields = mutableListOf() + activeFields.forEach { (name, value) -> fields.add(StringGene(name, value)) } + omittedFields.forEach { name -> + fields.add(OptionalGene(name, StringGene(name, ""), isActive = false)) + } + val obj = ObjectGene("body", fields = fields) + val typeGene = EnumGene("contentType", listOf("application/json")) + typeGene.index = 0 + return BodyParam(obj, typeGene) + } + + private fun jsonPutBodyParamOptionalInactive( + activeFields: Map + ): BodyParam { + val fields = mutableListOf() + activeFields.forEach { (name, value) -> fields.add(StringGene(name, value)) } + val obj = ObjectGene("body", fields = fields) + val optional = OptionalGene("body", obj, isActive = false) + val typeGene = EnumGene("contentType", listOf("application/json")) + typeGene.index = 0 + return BodyParam(optional, typeGene) + } + + private fun runMismatchedPutOracle( + path: String, + putBody: BodyParam?, + getResponseBody: String, + schema: RestSchema? = null, + getResponseStatus: Int = 200, + putResponseStatus: Int = 200 + ): Boolean { + val restPath = RestPath(path) + val put = RestCallAction( + id = "put", verb = HttpVerb.PUT, path = restPath, + parameters = if (putBody != null) mutableListOf(putBody) else mutableListOf() + ) + val get = RestCallAction( + id = "get", verb = HttpVerb.GET, path = restPath, + parameters = mutableListOf() + ) + + put.setLocalId("put-action") + get.setLocalId("get-action") + + val individual = RestIndividual( + mutableListOf(put, get), SampleType.RANDOM, dbInitialization = mutableListOf() + ) + individual.doInitialize() + + val putResult = RestCallResult(put.getLocalId()).apply { + setStatusCode(putResponseStatus) + setBody("{}") + setBodyType(javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE) + } + val getResult = RestCallResult(get.getLocalId()).apply { + setStatusCode(getResponseStatus) + setBody(getResponseBody) + setBodyType(javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE) + } + + return HttpSemanticsOracle.hasMismatchedPutResponse( + individual, + listOf(putResult, getResult), + schema + ) + } + + private fun buildUsersSchema( + putWritable: List, + getResponseFields: List + ): RestSchema { + fun props(names: List) = names.joinToString(",") { + "\"$it\":{\"type\":\"string\"}" + } + val putSchemaJson = "{\"type\":\"object\",\"properties\":{${props(putWritable)}}}" + val getSchemaJson = "{\"type\":\"object\",\"properties\":{${props(getResponseFields)}}}" + + val json = """ + { + "openapi": "3.0.0", + "info": { "title": "test", "version": "1.0" }, + "paths": { + "/users": { + "put": { + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": $putSchemaJson } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": $getSchemaJson } + } + } + } + }, + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": $getSchemaJson } + } + } + } + } + } + } + } + """.trimIndent() + + return RestSchema(OpenApiAccess.parseOpenApi(json, SchemaLocation.MEMORY)) + } + + @Test + fun testPut_sentFieldsMatch_returnsFalse() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice", "email" to "a@b.c")), + getResponseBody = """{"name":"Alice","email":"a@b.c"}""" + ) + assertFalse(mismatch) + } + + @Test + fun testPut_sentFieldHasDifferentValueInGet_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = """{"name":"Bob"}""" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_sentFieldMissingInGet_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice", "email" to "a@b.c")), + getResponseBody = """{"name":"Alice"}""" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_extraFieldInGetResponseIgnored_returnsFalse() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = """{"id":42,"name":"Alice","createdAt":"2026-01-01"}""" + ) + assertFalse(mismatch) + } + + @Test + fun testPut_wipedFieldStillPresentInGet_returnsTrue() { + val schema = buildUsersSchema( + putWritable = listOf("name", "email", "role"), + getResponseFields = listOf("id", "name", "email", "role", "createdAt") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice", "email" to "a@b.c"), + omittedFields = setOf("role") + ), + getResponseBody = """{"id":1,"name":"Alice","email":"a@b.c","role":"admin","createdAt":"2026-01-01"}""", + schema = schema + ) + assertTrue(mismatch) + } + + @Test + fun testPut_wipedFieldExplicitlyNullInGet_returnsFalse() { + val schema = buildUsersSchema( + putWritable = listOf("name", "role"), + getResponseFields = listOf("name", "role") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice"), + omittedFields = setOf("role") + ), + getResponseBody = """{"name":"Alice","role":null}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun testPut_wipedFieldAbsentInGet_returnsFalse() { + val schema = buildUsersSchema( + putWritable = listOf("name", "role"), + getResponseFields = listOf("name", "role") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice"), + omittedFields = setOf("role") + ), + getResponseBody = """{"name":"Alice"}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun testPut_changedField_returnsTrue() { + val schema = buildUsersSchema( + putWritable = listOf("name", "role"), + getResponseFields = listOf("id", "name", "role") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice", "role" to "admin"), + ), + getResponseBody = """{"id":"1","name":"Alice","role":"user"}""", + schema = schema + ) + assertTrue(mismatch) + } + + @Test + fun testPut_changedField_StringNull_returnsFalse() { + // PUT and GET both carry the literal 4-char string "null" for "name". + // Sent-fields path: values are equal, so no mismatch is reported. + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "null")), + getResponseBody = """{"name":"null"}""" + ) + assertFalse(mismatch) + } + + @Test + fun testPut_wipedField_StringNullInGet_returnsTrue() { + // PUT omits "role" (full replacement should wipe it). + // GET returns the literal 4-char string "null" - the field still holds + // a real value, distinct from JSON null. + val schema = buildUsersSchema( + putWritable = listOf("name", "role"), + getResponseFields = listOf("name", "role") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice"), + omittedFields = setOf("role") + ), + getResponseBody = """{"name":"Alice","role":"null"}""", + schema = schema + ) + assertTrue(mismatch) + } + + + @Test + fun testPut_writeOnlyFieldNotInGetSchema_noFalsePositive() { + // password is in PUT schema but NOT in GET schema (write-only). + // It was not sent. The wiped check must NOT flag this as a bug, even + // though there is no "password" field in the GET response. + val schema = buildUsersSchema( + putWritable = listOf("name", "password"), + getResponseFields = listOf("name") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = mapOf("name" to "Alice"), + omittedFields = setOf("password") + ), + getResponseBody = """{"name":"Alice"}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun testPut_putReturnedNon2xx_returnsFalse() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = """{"name":"Bob"}""", + putResponseStatus = 400 + ) + assertFalse(mismatch) + } + + @Test + fun testPut_getReturnedNon2xx_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = """{}""", + getResponseStatus = 404 + ) + assertTrue(mismatch) + } + + + @Test + fun testPut_allFieldsOmitted_getReturnsOnlyReadOnlySchemaFields_returnsFalse() { + val schema = buildUsersSchema( + putWritable = listOf("name", "email"), + getResponseFields = listOf("id", "name", "email", "createdAt") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = emptyMap(), + omittedFields = setOf("name", "email") + ), + getResponseBody = """{"id":42,"createdAt":"2026-01-01"}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun testPut_allFieldsOmitted_getReturnsWritableFieldsAsNull_returnsFalse() { + val schema = buildUsersSchema( + putWritable = listOf("name", "email"), + getResponseFields = listOf("id", "name", "email", "createdAt") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam( + activeFields = emptyMap(), + omittedFields = setOf("name", "email") + ), + getResponseBody = """{"id":42,"name":null,"email":null,"createdAt":"2026-01-01"}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun testPut_noBodyParam_getHasServerDefaults_returnsFalse() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = null, + getResponseBody = """{"id":42,"name":"default","createdAt":"2026-01-01"}""" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_noBodyParam_getAlsoEmpty_returnsFalse() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = null, + getResponseBody = "" + ) + assertFalse(mismatch) + } + + @Test + fun testPut_bodyOptionalGeneInactive_getHasContent_returnsTrue() { + // Outer OptionalGene wrapping the body is inactive — nothing was sent. + // The inner ObjectGene's fields must NOT be treated as sent fields. + // Equivalent to "no body": GET returning content is flagged. + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParamOptionalInactive( + activeFields = mapOf("name" to "Bob") + ), + getResponseBody = """{"name":"Bob"}""" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_nonEmptyPutBody_getEmptyString_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = "" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_nonEmptyPutBody_getEmptyJsonObject_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = "{}" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_nonEmptyPutBody_getLiteralNull_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = "null" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_nonEmptyPutBody_getGarbageBody_returnsTrue() { + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = jsonPutBodyParam(activeFields = mapOf("name" to "Alice")), + getResponseBody = "not a valid json body" + ) + assertTrue(mismatch) + } + + @Test + fun testPut_noBodyParam_schemaProvided_getStillShowsWritableFields_returnsTrue() { + val schema = buildUsersSchema( + putWritable = listOf("name", "email"), + getResponseFields = listOf("id", "name", "email", "createdAt") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = null, + getResponseBody = """{"id":1,"name":"Alice","email":"a@a.com","createdAt":"2026-01-01"}""", + schema = schema + ) + assertTrue(mismatch) + } + + @Test + fun testPut_noBodyParam_schemaProvided_getHasOnlyReadOnlyFields_returnsFalse() { + val schema = buildUsersSchema( + putWritable = listOf("name", "email"), + getResponseFields = listOf("id", "name", "email", "createdAt") + ) + val mismatch = runMismatchedPutOracle( + path = "/users", + putBody = null, + getResponseBody = """{"id":42,"createdAt":"2026-01-01"}""", + schema = schema + ) + assertFalse(mismatch) + } + + @Test + fun test_extractBodyObject(){ + val put = RestCallAction( + id = "put", verb = HttpVerb.PUT, path = RestPath("/users"), + parameters = mutableListOf() + ) + + } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtilsTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtilsTest.kt new file mode 100644 index 0000000000..9adb440f0f --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/schema/SchemaUtilsTest.kt @@ -0,0 +1,546 @@ +package org.evomaster.core.problem.rest.schema + +import org.evomaster.core.problem.rest.StatusGroup +import org.evomaster.core.problem.rest.data.HttpVerb +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SchemaUtilsTest { + + private fun parse(json: String): RestSchema = + RestSchema(OpenApiAccess.parseOpenApi(json.trimIndent(), SchemaLocation.MEMORY)) + + @Test + fun testExtractPutRequestSchemaFields_inlineProperties() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + } + } + """) + assertEquals(setOf("name", "email"), SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT)) + } + + @Test + fun testExtractPutRequestSchemaFields_topLevelRef() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": {"${'$'}ref": "#/components/schemas/User"} + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "role": {"type": "string"} + } + } + } + } + } + """) + assertEquals(setOf("name", "role"), SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT)) + } + + @Test + fun testExtractPutRequestSchemaFields_allOfComposition() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + {"${'$'}ref": "#/components/schemas/Base"}, + { + "type": "object", + "properties": { + "extra": {"type": "string"} + } + } + ] + } + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Base": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + } + } + } + } + } + """) + assertEquals(setOf("id", "name", "extra"), SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT)) + } + + @Test + fun testExtractPutRequestSchemaFields_nestedAllOf() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": {"${'$'}ref": "#/components/schemas/Outer"} + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Inner": { + "type": "object", + "properties": {"a": {"type": "string"}} + }, + "Outer": { + "allOf": [ + {"${'$'}ref": "#/components/schemas/Inner"}, + { + "type": "object", + "properties": {"b": {"type": "string"}} + } + ] + } + } + } + } + """) + assertEquals(setOf("a", "b"), SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT)) + } + + @Test + fun testExtractPutRequestSchemaFields_oneOfIgnored() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "string"}}} + ] + } + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + } + } + """) + // oneOf/anyOf are intentionally not merged; with no top-level properties result is empty. + assertTrue(SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT).isEmpty()) + } + + @Test + fun testExtractPutRequestSchemaFields_pathMissing() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {} + } + """) + assertTrue(SchemaUtils.extractRequestBodySchemaFields(schema, "/missing", HttpVerb.PUT).isEmpty()) + } + + @Test + fun testExtractPutRequestSchemaFields_requestBodyMissing() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "put": {"responses": {"200": {"description": "ok"}}} + } + } + } + """) + assertTrue(SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT).isEmpty()) + } + + @Test + fun testExtractGetResponseSchemaFields_2xxFirstMatch() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + } + } + } + } + } + } + } + } + } + } + """) + assertEquals(setOf("id", "name"), SchemaUtils.extractResponseSchemaFields(schema, "/x", HttpVerb.GET, statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_2xx))) + } + + @Test + fun testExtractGetResponseSchemaFields_defaultFallback() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "get": { + "responses": { + "default": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"only": {"type": "string"}} + } + } + } + } + } + } + } + } + } + """) + assertEquals(setOf("only"), SchemaUtils.extractResponseSchemaFields(schema, "/x", HttpVerb.GET, statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_2xx))) + } + + @Test + fun testCollectPropertyNames_allOfSelfReference() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + "components": { + "schemas": { + "Node": { + "allOf": [ + {"${'$'}ref": "#/components/schemas/Node"}, + {"type": "object", "properties": {"name": {"type": "string"}}} + ] + } + } + } + } + """) + val node = schema.main.schemaParsed.components.schemas["Node"]!! + // allOf contains a self-$ref; visitedRefs must break the recursion without blowing up. + assertEquals(setOf("name"), SchemaUtils.collectPropertyNames(schema, node)) + } + + // ------------------------------------------------------------------------- + // Generic extractRequestBodySchemaFields / extractResponseSchemaFields + // ------------------------------------------------------------------------- + + @Test + fun testExtractRequestBodySchemaFields_postVerb() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}} + } + } + } + }, + "responses": {"201": {"description": "created"}} + } + } + } + } + """) + assertEquals( + setOf("a", "b"), + SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.POST) + ) + } + + @Test + fun testExtractRequestBodySchemaFields_verbAbsent() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "get": {"responses": {"200": {"description": "ok"}}} + } + } + } + """) + assertTrue( + SchemaUtils.extractRequestBodySchemaFields(schema, "/x", HttpVerb.PUT).isEmpty() + ) + } + + @Test + fun testExtractResponseSchemaFields_4xxGroup() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "post": { + "responses": { + "400": { + "description": "bad", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"errorCode": {"type": "string"}} + } + } + } + } + } + } + } + } + } + """) + assertEquals( + setOf("errorCode"), + SchemaUtils.extractResponseSchemaFields( + schema, "/x", HttpVerb.POST, + statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_4xx) + ) + ) + } + + @Test + fun testExtractResponseSchemaFields_exactStatus201() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "post": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"ok": {"type": "string"}}} + } + } + }, + "201": { + "description": "created", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"id": {"type": "string"}}} + } + } + } + } + } + } + } + } + """) + // 2xx group: would pick the FIRST 2xx (likely "200"); exact 201 forces the 201 schema. + assertEquals( + setOf("id"), + SchemaUtils.extractResponseSchemaFields( + schema, "/x", HttpVerb.POST, + statusMatcher = SchemaUtils.statusCodeMatcher(201) + ) + ) + } + + @Test + fun testExtractResponseSchemaFields_anyOf200or201() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "post": { + "responses": { + "201": { + "description": "created", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"id": {"type": "string"}}} + } + } + } + } + } + } + } + } + """) + // 200 not present, 201 is — matcher must pick 201 + assertEquals( + setOf("id"), + SchemaUtils.extractResponseSchemaFields( + schema, "/x", HttpVerb.POST, + statusMatcher = SchemaUtils.statusCodesMatcher(200, 201) + ) + ) + } + + @Test + fun testExtractResponseSchemaFields_noMatch_noFallback() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "get": { + "responses": { + "default": { + "description": "ok", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"only": {"type": "string"}}} + } + } + } + } + } + } + } + } + """) + // No 2xx and fallbackToDefault=false → empty + assertTrue( + SchemaUtils.extractResponseSchemaFields( + schema, "/x", HttpVerb.GET, + statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_2xx), + fallbackToDefault = false + ).isEmpty() + ) + } + + @Test + fun testExtractResponseSchemaFields_3xxGroup() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": { + "get": { + "responses": { + "302": { + "description": "redirect", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"location": {"type": "string"}}} + } + } + } + } + } + } + } + } + """) + assertEquals( + setOf("location"), + SchemaUtils.extractResponseSchemaFields( + schema, "/x", HttpVerb.GET, + statusMatcher = SchemaUtils.statusGroupMatcher(StatusGroup.G_3xx) + ) + ) + } +}