Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String>) {
SpringApplication.run(InvalidMergePatchApplication::class.java, *args)
}

private val buggyData = mutableMapOf<Int, MergePatchResource>()
private val correctData = mutableMapOf<Int, MergePatchResource>()

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<String>? = null,

@field:Schema(nullable = true)
val value: Optional<Int>? = null
)

// ----------------------------------------------------------------------
// buggy resource
// ----------------------------------------------------------------------

@PostMapping("/api/mergepatch/buggy")
open fun createBuggy(@RequestBody body: MergePatchResource): ResponseEntity<MergePatchResource> {
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<MergePatchResource> {
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<MergePatchResource> {

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<MergePatchResource> {
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<MergePatchResource> {
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<MergePatchResource> {

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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<String> ->

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/") })
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionResult>
): 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<String>
): 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<String, String> {
return body.split("&").mapNotNull { pair ->
val parts = pair.split("=", limit = 2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ class HttpSemanticsService : TimeBoxedPhase{
if(hasPhaseTimedOut()) return
partialUpdatePut()

if(hasPhaseTimedOut()) return
mergePatchSideEffect()

if(hasPhaseTimedOut()) return
misleadingCreatePut()

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,10 @@ abstract class AbstractRestFitness : HttpWsFitness<RestIndividual>() {
if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_INVALID_ALLOW)) {
handleInvalidAllow(individual, actionResults, fv)
}

if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HTTP_INVALID_MERGE_PATCH)) {
handleInvalidMergePatch(individual, actionResults, fv)
}
}

/**
Expand Down Expand Up @@ -1488,6 +1492,23 @@ abstract class AbstractRestFitness : HttpWsFitness<RestIndividual>() {
ar.addFault(DetectedFault(category, put.getName(), null))
}

private fun handleInvalidMergePatch(
individual: RestIndividual,
actionResults: List<ActionResult>,
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<ActionResult>,
Expand Down
Loading