diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchApplication.kt new file mode 100644 index 0000000000..85b8bb8aeb --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchApplication.kt @@ -0,0 +1,130 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidmergepatch + +import io.swagger.v3.oas.annotations.media.Schema +import java.net.URI +import java.util.Optional +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +/** + * SUT for RFC 7386 (JSON Merge Patch). Two resources under /api/mergepatch: + * - /buggy : PATCH overwrites every field, clobbering ones absent from the body (bug). + * - /correct : PATCH only changes fields present in the body. + */ +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +open class InvalidMergePatchApplication { + + companion object { + + const val MERGE_PATCH = "application/merge-patch+json" + + @JvmStatic + fun main(args: Array) { + SpringApplication.run(InvalidMergePatchApplication::class.java, *args) + } + + private val buggyData = mutableMapOf() + private val correctData = mutableMapOf() + + fun reset() { + buggyData.clear() + correctData.clear() + } + } + + data class MergePatchResource( + var name: String? = null, + var value: Int? = null + ) + + class MergeRequest( + var name: String? = null, + var value: Int? = null + ) + + // proper merge-patch body: null = absent (untouched), Optional.empty = delete, Optional.of = set + class MergePatchDto( + @field:Schema(nullable = true) + val name: Optional? = null, + + @field:Schema(nullable = true) + val value: Optional? = null + ) + + // ---------------------------------------------------------------------- + // buggy resource + // ---------------------------------------------------------------------- + + @PostMapping("/api/mergepatch/buggy") + open fun createBuggy(@RequestBody body: MergePatchResource): ResponseEntity { + val id = buggyData.size + 1 + val stored = body.copy() + buggyData[id] = stored + return ResponseEntity.created(URI.create("/api/mergepatch/buggy/$id")).body(stored) + } + + @GetMapping("/api/mergepatch/buggy/{id}") + open fun getBuggy(@PathVariable("id") id: Int): ResponseEntity { + val resource = buggyData[id] ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PatchMapping("/api/mergepatch/buggy/{id}", consumes = [MERGE_PATCH]) + open fun patchBuggy( + @PathVariable("id") id: Int, + @RequestBody body: MergeRequest + ): ResponseEntity { + + val resource = buggyData[id] ?: return ResponseEntity.status(404).build() + + // BUG: overwrites every field unconditionally. A body of {"name":"x"} makes + // 'value' arrive as null and wipes the stored value -> PATCH acts like PUT. + resource.name = body.name + resource.value = body.value + + return ResponseEntity.status(200).body(resource) + } + + // ---------------------------------------------------------------------- + // correct resource + // ---------------------------------------------------------------------- + + @PostMapping("/api/mergepatch/correct") + open fun createCorrect(@RequestBody body: MergePatchResource): ResponseEntity { + val id = correctData.size + 1 + val stored = body.copy() + correctData[id] = stored + return ResponseEntity.created(URI.create("/api/mergepatch/correct/$id")).body(stored) + } + + @GetMapping("/api/mergepatch/correct/{id}") + open fun getCorrect(@PathVariable("id") id: Int): ResponseEntity { + val resource = correctData[id] ?: return ResponseEntity.status(404).build() + return ResponseEntity.status(200).body(resource) + } + + @PatchMapping("/api/mergepatch/correct/{id}", consumes = [MERGE_PATCH]) + open fun patchCorrect( + @PathVariable("id") id: Int, + @RequestBody body: MergePatchDto + ): ResponseEntity { + + val resource = correctData[id] ?: return ResponseEntity.status(404).build() + + // RFC 7386: absent (null property) -> untouched; Optional.empty -> delete; + // Optional.of(v) -> set. Only fields actually present in the body are applied. + body.name?.let { resource.name = it.orElse(null) } + body.value?.let { resource.value = it.orElse(null) } + + return ResponseEntity.status(200).body(resource) + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchController.kt new file mode 100644 index 0000000000..534f98532e --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidmergepatch/InvalidMergePatchController.kt @@ -0,0 +1,10 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidmergepatch + +import com.foo.rest.examples.spring.openapi.v3.SpringController + +class InvalidMergePatchController : SpringController(InvalidMergePatchApplication::class.java) { + + override fun resetStateOfSUT() { + InvalidMergePatchApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidmergepatch/HttpInvalidMergePatchEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidmergepatch/HttpInvalidMergePatchEMTest.kt new file mode 100644 index 0000000000..132ae6916b --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidmergepatch/HttpInvalidMergePatchEMTest.kt @@ -0,0 +1,55 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.invalidmergepatch + +import com.foo.rest.examples.spring.openapi.v3.httporacle.invalidmergepatch.InvalidMergePatchController +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.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class HttpInvalidMergePatchEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(InvalidMergePatchController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpInvalidMergePatchEM", + 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) + + // both endpoints must be exercised with a successful partial PATCH + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/api/mergepatch/buggy/{id}", null) + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/api/mergepatch/correct/{id}", null) + + val faults = DetectedFaultUtils.getDetectedFaultCategories(solution) + assertTrue(faults.contains(ExperimentalFaultCategory.HTTP_INVALID_MERGE_PATCH)) + + val mergePatchFaults = DetectedFaultUtils.getDetectedFaults(solution) + .filter { it.category == ExperimentalFaultCategory.HTTP_INVALID_MERGE_PATCH } + // the buggy resource must be flagged... + assertTrue(mergePatchFaults.any { it.operationId.contains("/api/mergepatch/buggy/") }) + // ...and the correct resource must NOT be flagged + assertTrue(mergePatchFaults.none { it.operationId.contains("/api/mergepatch/correct/") }) + } + } +} 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 4ed49a4bef..70d4b4ea26 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_INVALID_ALLOW(919, "Invalid allow", "invalidAllow", "TODO"), + HTTP_INVALID_MERGE_PATCH(922, "JSON Merge Patch changes untouched fields", "invalidMergePatch", + "TODO"), HTTP_STATUS_NO_NON_STANDARD_CODES(950, "no-non-standard-codes", "invalidStatusCode", "TODO"), HTTP_STATUS_NO_201_IF_DELETE(951, "no-201-if-delete", "201OnDelete", "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 8e453e1cd7..70aecefe4e 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 @@ -725,6 +725,81 @@ object HttpSemanticsOracle { return resFollow.getStatusCode() == 404 } + /** + * HTTP_INVALID_MERGE_PATCH oracle (RFC 7386): a partial merge-patch update must not + * change fields that were NOT part of the request body. + * + * Sequence checked (last three main actions): + * GET /path -> 2xx (state before) + * PATCH /path -> 2xx (merge-patch, sends only some fields) + * GET /path -> 2xx (state after) + * + * X = fields declared in the PATCH body but NOT sent in this request (undefined; + * an explicit null counts as sent, meaning "delete"). Every field in X that had a + * value before must read back with the same value after; otherwise the server treated + * the partial update as a full replacement. + */ + fun hasInvalidMergePatch( + individual: RestIndividual, + actionResults: List + ): Boolean { + + if (individual.size() < 3) return false + + val actions = individual.seeMainExecutableActions() + val before = actions[actions.size - 3] + val patch = actions[actions.size - 2] + val after = actions[actions.size - 1] + + if (before.verb != HttpVerb.GET) return false + if (patch.verb != HttpVerb.PATCH) return false + if (after.verb != HttpVerb.GET) return false + + if (!before.usingSameResolvedPath(patch) || !after.usingSameResolvedPath(patch)) return false + // the two GETs must use the same auth for a meaningful state comparison + if (before.auth.isDifferentFrom(after.auth)) return false + + // merge-patch bodies are JSON (incl. application/merge-patch+json) + val bodyParam = patch.parameters.find { it is BodyParam } as BodyParam? + if (bodyParam != null && !bodyParam.isJson()) return false + + val resBefore = actionResults.find { it.sourceLocalId == before.getLocalId() } as RestCallResult? ?: return false + val resPatch = actionResults.find { it.sourceLocalId == patch.getLocalId() } as RestCallResult? ?: return false + val resAfter = actionResults.find { it.sourceLocalId == after.getLocalId() } as RestCallResult? ?: return false + + if (!StatusGroup.G_2xx.isInGroup(resBefore.getStatusCode())) return false + if (!StatusGroup.G_2xx.isInGroup(resPatch.getStatusCode())) return false + if (!StatusGroup.G_2xx.isInGroup(resAfter.getStatusCode())) return false + + val untouched = extractModifiedFieldNames(patch) - extractSentFieldNames(patch) + if (untouched.isEmpty()) return false + + val bodyBefore = resBefore.getBody() + val bodyAfter = resAfter.getBody() + if (bodyBefore.isNullOrEmpty() || bodyAfter.isNullOrEmpty()) return false + + return hasChangedUntouchedFields(bodyBefore, bodyAfter, untouched) + } + + /** + * True if any field in [untouchedFields] that was present in [bodyBefore] is missing or + * has a different value in [bodyAfter]. Fields absent from the before-GET are ignored. + */ + internal fun hasChangedUntouchedFields( + bodyBefore: String, + bodyAfter: String, + untouchedFields: Set + ): Boolean { + val fieldsBefore = OutputFormatter.JSON_FORMATTER.readFields(bodyBefore, untouchedFields) ?: return false + val fieldsAfter = OutputFormatter.JSON_FORMATTER.readFields(bodyAfter, untouchedFields) ?: return false + + for (field in untouchedFields) { + val valueBefore = fieldsBefore[field] ?: continue + if (valueBefore != fieldsAfter[field]) return true + } + return false + } + 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 72f54be303..f942db29b6 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 @@ -119,6 +119,9 @@ class HttpSemanticsService : TimeBoxedPhase{ if(hasPhaseTimedOut()) return partialUpdatePut() + if(hasPhaseTimedOut()) return + mergePatchSideEffect() + if(hasPhaseTimedOut()) return misleadingCreatePut() @@ -480,6 +483,60 @@ class HttpSemanticsService : TimeBoxedPhase{ } } + /** + * HTTP_INVALID_MERGE_PATCH oracle (RFC 7386): a partial merge-patch must not change fields + * absent from the request body. Slice an individual at a 2xx PATCH (keeping the creation), + * then wrap that PATCH with a GET before and after: + * [...create...] GET /X -> PATCH /X (2xx) -> GET /X + */ + private fun mergePatchSideEffect() { + + val patchOperations = RestIndividualSelectorUtils.getAllActionDefinitions(actionDefinitions, HttpVerb.PATCH) + + patchOperations.forEach { patchOp -> + + if (hasPhaseTimedOut()) return + + val getDef = actionDefinitions.find { it.verb == HttpVerb.GET && it.path == patchOp.path } + ?: return@forEach + + val successPatches = RestIndividualSelectorUtils.findIndividuals( + individualsInSolution, HttpVerb.PATCH, patchOp.path, statusGroup = StatusGroup.G_2xx + ) + if (successPatches.isEmpty()) return@forEach + + for (candidate in successPatches.sortedBy { it.individual.size() }) { + + if (hasPhaseTimedOut()) return + + val ind = RestIndividualBuilder.sliceAllCallsInIndividualAfterAction( + candidate, HttpVerb.PATCH, patchOp.path, statusGroup = StatusGroup.G_2xx + ) + + val patch = ind.seeMainExecutableActions().last() + val size = ind.seeMainExecutableActions().size + + // bind the GET to the PATCH's resolved path (so usingSameResolvedPath holds). if the + // PATCH takes its id from a creation's Location header, also link the GET to it + val creator = patch.usePreviousLocationId?.let { locId -> + ind.seeMainExecutableActions().firstOrNull { + (it.verb == HttpVerb.POST || it.verb == HttpVerb.PUT) + && it.saveCreatedResourceLocation && it.creationLocationId() == locId + } + } + val getBefore = builder.createBoundActionFor(getDef, patch) + creator?.saveAndLinkLocationTo(getBefore) + ind.addMainActionInEmptyEnterpriseGroup(size - 1, getBefore) + + val getAfter = builder.createBoundActionFor(getDef, patch) + creator?.saveAndLinkLocationTo(getAfter) + ind.addMainActionInEmptyEnterpriseGroup(-1, getAfter) + + prepareEvaluateAndSave(ind) + } + } + } + /** * HTTP_MISLEADING_CREATE_PUT oracle: a PUT that returns 201 claims it created a new resource. 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 ae5a63def9..732960cc33 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 @@ -1346,6 +1346,10 @@ abstract class AbstractRestFitness : HttpWsFitness() { if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_INVALID_ALLOW)) { handleInvalidAllow(individual, actionResults, fv) } + + if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_INVALID_MERGE_PATCH)) { + handleInvalidMergePatch(individual, actionResults, fv) + } } /** @@ -1488,6 +1492,23 @@ abstract class AbstractRestFitness : HttpWsFitness() { ar.addFault(DetectedFault(category, put.getName(), null)) } + private fun handleInvalidMergePatch( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!HttpSemanticsOracle.hasInvalidMergePatch(individual, actionResults)) return + + val patch = individual.seeMainExecutableActions().filter { it.verb == HttpVerb.PATCH }.last() + + val category = ExperimentalFaultCategory.HTTP_INVALID_MERGE_PATCH + val scenarioId = idMapper.handleLocalTarget(idMapper.getFaultDescriptiveId(category, patch.getName())) + fv.updateTarget(scenarioId, 1.0, individual.seeMainExecutableActions().lastIndex) + + val ar = actionResults.find { it.sourceLocalId == patch.getLocalId() } as RestCallResult? ?: return + ar.addFault(DetectedFault(category, patch.getName(), null)) + } + private fun handleMisleadingCreatePut( individual: RestIndividual, actionResults: List,