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/problem/enterprise/ExperimentalFaultCategory.kt b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt index 32381812d7..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 @@ -39,6 +39,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"), //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 d4630dc56d..866e8a3bd6 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,15 +1,19 @@ package org.evomaster.core.problem.rest.oracle +import io.swagger.v3.oas.models.media.Schema import org.evomaster.core.output.formatter.OutputFormatter 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.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 +251,224 @@ 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? + 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 (!putBody.isNullOrEmpty() && getBody.isNullOrEmpty()) return true + if (getBody.isNullOrEmpty()) return false + + val sentFields = extractSentFieldNames(put) + val allPutSchemaFields = extractModifiedFieldNames(put).ifEmpty { + schema?.let { extractPutRequestSchemaFields(it, 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 = extractGetResponseSchemaFields(schema, get) + 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 (isWipedFieldStillPresent(getWiped[field])) 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) + } + + /** + * A wiped field is present only if the GET response returns a non-null, non-empty value. + */ + private fun isWipedFieldStillPresent(value: String?): Boolean { + if (value == null) return false + if (value.isEmpty()) return false + if (value == "null") return false + return true + } + /** * 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? + return 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() } /** @@ -365,6 +569,56 @@ object HttpSemanticsOracle { return false } + /** + * Returns the property names from the PUT request body schema in the OpenAPI spec. + * Used as a fallback to determine writable fields when no BodyParam is present on the action. + */ + internal fun extractPutRequestSchemaFields( + schema: RestSchema, + put: RestCallAction + ): Set { + + val openAPI = schema.main.schemaParsed + val pathItem = openAPI.paths?.get(put.path.toString()) ?: return emptySet() + val op = pathItem.put ?: return emptySet() + val requestBody = op.requestBody ?: return emptySet() + val mediaType = requestBody.content?.values?.firstOrNull() ?: return emptySet() + val rawSchema = mediaType.schema ?: return emptySet() + val resolved = rawSchema.`$ref`?.let { + SchemaUtils.getReferenceSchema(schema, schema.main, it, mutableListOf()) + } ?: rawSchema + + return resolved.properties?.keys?.toSet() ?: emptySet() + } + + /** + * Returns the property names from the GET 2xx response schema in the OpenAPI spec. + * Empty set if unresolvable, which makes callers skip wiped-field checks. + */ + internal fun extractGetResponseSchemaFields( + schema: RestSchema, + get: RestCallAction + ): Set { + + val openAPI = schema.main.schemaParsed + val pathItem = openAPI.paths?.get(get.path.toString()) ?: return emptySet() + val op = pathItem.get ?: return emptySet() + + // pick the first 2xx response, falling back to "default" + val response = op.responses?.entries + ?.firstOrNull { it.key.length == 3 && it.key.startsWith("2") }?.value + ?: op.responses?.get("default") + ?: return emptySet() + + val mediaType = response.content?.values?.firstOrNull() ?: return emptySet() + val rawSchema = mediaType.schema ?: return emptySet() + val resolved = rawSchema.`$ref`?.let { + SchemaUtils.getReferenceSchema(schema, schema.main, it, mutableListOf()) + } ?: rawSchema + + return resolved.properties?.keys?.toSet() ?: emptySet() + } + private fun parseFormBody(body: String): Map { return body.split("&").mapNotNull { pair -> val parts = pair.split("=", limit = 2) 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 872b53a531..1973c0defc 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 @@ -99,6 +99,7 @@ class HttpSemanticsService { putRepeatedCreated() sideEffectsOfFailedModification() + partialUpdatePut() } /** @@ -372,5 +373,42 @@ class HttpSemanticsService { 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 cc5ffdb03f..0f6085a36e 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 @@ -1212,10 +1212,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( @@ -1295,6 +1299,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..b0d8cbd48b 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,393 @@ 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 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_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_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) + } }