diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/deleteonly/HttpInvalidLocationDeleteOnlyApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/deleteonly/HttpInvalidLocationDeleteOnlyApplication.kt new file mode 100644 index 0000000000..7dad662fcb --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/deleteonly/HttpInvalidLocationDeleteOnlyApplication.kt @@ -0,0 +1,46 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.deleteonly + +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.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +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/products"]) +@RestController +open class HttpInvalidLocationDeleteOnlyApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(HttpInvalidLocationDeleteOnlyApplication::class.java, *args) + } + + fun reset() {} + } + + // Creator: returns a Location pointing to a constraint resource whose path is + // declared only for DELETE (no GET). Mirrors the features-service chain. The + // referenced constraint is never actually stored. + @PutMapping(path = ["/{productName}/constraints"]) + open fun createConstraint(@PathVariable("productName") productName: String): ResponseEntity { + return ResponseEntity.status(201) + .header("Location", "/api/products/$productName/constraints/123") + .build() + } + + // The Location target is declared only for DELETE. A GET would be 405, so the oracle + // must probe with DELETE; that DELETE returns 404 because the constraint does not exist. + @DeleteMapping(path = ["/{productName}/constraints/{constraintId}"]) + open fun deleteConstraint( + @PathVariable("productName") productName: String, + @PathVariable("constraintId") constraintId: String + ): ResponseEntity { + return ResponseEntity.status(404).build() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/verbselection/HttpInvalidLocationVerbSelectionApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/verbselection/HttpInvalidLocationVerbSelectionApplication.kt new file mode 100644 index 0000000000..039623100e --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/verbselection/HttpInvalidLocationVerbSelectionApplication.kt @@ -0,0 +1,68 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.verbselection + +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.PutMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Single SUT exercising the extended HTTP_INVALID_LOCATION behaviour: + * - status codes beyond 404 (405, 500, 501) + * - follow-up verb chosen from the schema, not hardcoded GET + * + * Each family has a creator that returns a Location pointing to a target whose + * declared verbs / returned status differ, so the oracle exercises a distinct branch. + */ +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +open class HttpInvalidLocationVerbSelectionApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(HttpInvalidLocationVerbSelectionApplication::class.java, *args) + } + + fun reset() {} + } + + // Family A: Location target is a declared GET that returns 500. + // Follow-up verb selected: GET. Proves 500 is treated as an invalid Location. + @PutMapping(path = ["/api/a/{id}"]) + open fun createA(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(201).header("Location", "/api/a/$id/child").build() + + @GetMapping(path = ["/api/a/{id}/child"]) + open fun childA(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(500).build() + + // Family B: Location target is a declared GET that returns 501. + // Proves 501 is treated as an invalid Location. + @PutMapping(path = ["/api/b/{id}"]) + open fun createB(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(201).header("Location", "/api/b/$id/child").build() + + @GetMapping(path = ["/api/b/{id}/child"]) + open fun childB(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(501).build() + + // Family C: Location target is declared with PUT and PATCH only (no GET, no DELETE). + // Priority order GET,DELETE,POST,PUT,PATCH -> PUT is selected. The PUT returns 405, + // proving both the verb-priority selection and that 405 is treated as invalid. + @PutMapping(path = ["/api/c/{id}"]) + open fun createC(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(201).header("Location", "/api/c/$id/child").build() + + @PutMapping(path = ["/api/c/{id}/child"]) + open fun putChildC(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(405).build() + + @PatchMapping(path = ["/api/c/{id}/child"]) + open fun patchChildC(@PathVariable("id") id: String): ResponseEntity = + ResponseEntity.status(405).build() +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyController.kt new file mode 100644 index 0000000000..83873ed0d0 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyController.kt @@ -0,0 +1,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.deleteonly + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.deleteonly.HttpInvalidLocationDeleteOnlyApplication + + +class HttpInvalidLocationDeleteOnlyController: SpringController(HttpInvalidLocationDeleteOnlyApplication::class.java){ + + override fun resetStateOfSUT() { + HttpInvalidLocationDeleteOnlyApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionController.kt new file mode 100644 index 0000000000..bcc2d44711 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionController.kt @@ -0,0 +1,12 @@ +package com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.verbselection + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.verbselection.HttpInvalidLocationVerbSelectionApplication + + +class HttpInvalidLocationVerbSelectionController: SpringController(HttpInvalidLocationVerbSelectionApplication::class.java){ + + override fun resetStateOfSUT() { + HttpInvalidLocationVerbSelectionApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyEMTest.kt new file mode 100644 index 0000000000..ffaa8ba244 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationDeleteOnlyEMTest.kt @@ -0,0 +1,49 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.invalidlocation + +import com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.deleteonly.HttpInvalidLocationDeleteOnlyController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class HttpInvalidLocationDeleteOnlyEMTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HttpInvalidLocationDeleteOnlyController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpInvalidLocationDeleteOnlyEM", + 20 + ) { 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) + + // The Location points to a resource declared only for DELETE (no GET), so a GET + // would be 405. The oracle must probe with DELETE and flag the 404 it returns. + val faults = DetectedFaultUtils.getDetectedFaultCategories(solution) + assertTrue(ExperimentalFaultCategory.HTTP_INVALID_LOCATION in faults) + + val locationFaults = DetectedFaultUtils.getDetectedFaults(solution) + .filter { it.category == ExperimentalFaultCategory.HTTP_INVALID_LOCATION } + assertTrue(locationFaults.any { it.operationId.contains("/api/products/") }) + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionEMTest.kt new file mode 100644 index 0000000000..1a9af0c8bc --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/httporacle/invalidlocation/HttpInvalidLocationVerbSelectionEMTest.kt @@ -0,0 +1,72 @@ +package org.evomaster.e2etests.spring.openapi.v3.httporacle.invalidlocation + +import com.foo.rest.examples.spring.openapi.v3.httporacle.invalidlocation.verbselection.HttpInvalidLocationVerbSelectionController +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 HttpInvalidLocationVerbSelectionEMTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HttpInvalidLocationVerbSelectionController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HttpInvalidLocationVerbSelectionEM", + 50 + ) { 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) + + val faults = DetectedFaultUtils.getDetectedFaults(solution) + .filter { it.category == ExperimentalFaultCategory.HTTP_INVALID_LOCATION } + + // Family A: Location -> declared GET returning 500. A fault here can only come from + // the 500 status, proving 500 is part of the invalid-location status set. + assertTrue(faults.any { it.operationId.contains("/api/a/") }) + // Family B: Location -> declared GET returning 501. Proves 501. + assertTrue(faults.any { it.operationId.contains("/api/b/") }) + // Family C: Location -> PUT/PATCH-only target returning 405. Proves 405. + assertTrue(faults.any { it.operationId.contains("/api/c/") }) + + // Verb selection is proven by inspecting the generated follow-up calls (the last + // action, chained to the previous Location via usePreviousLocationId). + val followUps = solution.individuals.mapNotNull { ei -> + val actions = ei.individual.seeMainExecutableActions() + if (actions.size < 2) return@mapNotNull null + val creator = actions[actions.size - 2] + val follow = actions[actions.size - 1] + if (follow.usePreviousLocationId.isNullOrBlank()) null else Pair(creator, follow) + } + + // Family C target declares only PUT and PATCH; priority order must pick PUT (not PATCH, + // and certainly not a default GET). + assertTrue(followUps.any { (creator, follow) -> + creator.path.toString().contains("/api/c/") && follow.verb == HttpVerb.PUT + }) + // Family A target declares GET, so GET must be selected. + assertTrue(followUps.any { (creator, follow) -> + creator.path.toString().contains("/api/a/") && follow.verb == HttpVerb.GET + }) + } + } +} 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 b3a323cddc..2e3a9aa117 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 @@ -17,6 +17,20 @@ import org.evomaster.core.search.gene.wrapper.OptionalGene object HttpSemanticsOracle { + /** + * Verbs that the Location follow-up call may use (see HttpSemanticsService). + */ + //TODO update when QUERY is going to be added + private val LOCATION_FOLLOWUP_VERBS = setOf( + HttpVerb.GET, HttpVerb.DELETE, HttpVerb.POST, HttpVerb.PUT, HttpVerb.PATCH + ) + + /** + * A Location follow-up returning one of these is treated as an invalid Location: + * 404, 405, 500, 501. + */ + private val INVALID_LOCATION_STATUS = setOf(404, 405, 500, 501) + fun hasRepeatedCreatePut(individual: RestIndividual, @@ -692,12 +706,13 @@ object HttpSemanticsOracle { * Checks the invalid-location oracle: * * ANY /X -> response with Location header L - * GET L -> 404 (BUG: location does not point to an existing resource) + * VERB L -> 404/405/500/501 (BUG: location does not point to a usable resource) * * Sequence checked: the last two main actions of the individual. * - second-to-last (previous) — any verb — whose result has a non-blank Location header. - * - last is a GET bound to that Location via [RestCallAction.usePreviousLocationId]. - * - last action's response is 404. + * - last is bound to that Location via [RestCallAction.usePreviousLocationId]. Its verb is + * chosen from the schema (GET when the Location path is undeclared), so it is not GET-only. + * - last action's response is in [INVALID_LOCATION_STATUS]. */ fun hasInvalidLocation( individual: RestIndividual, @@ -710,11 +725,9 @@ object HttpSemanticsOracle { val previous = actions[actions.size - 2] val follow = actions[actions.size - 1] - // follow-up must be a GET, and it must actually be chained to the previous Location - if (follow.verb != HttpVerb.GET) return false + if (follow.verb !in LOCATION_FOLLOWUP_VERBS) return false if (follow.usePreviousLocationId.isNullOrBlank()) return false - // same auth so a 404 cannot be confused with an authorization problem if (previous.auth.isDifferentFrom(follow.auth)) return false val resPrevious = actionResults.find { it.sourceLocalId == previous.getLocalId() } as RestCallResult? @@ -725,8 +738,8 @@ object HttpSemanticsOracle { // the only structural precondition on the previous response is a non-blank Location header if (resPrevious.getLocation().isNullOrBlank()) return false - // BUG: a GET on that Location returns 404 - return resFollow.getStatusCode() == 404 + // BUG: the follow-up on that Location returns 404/405/500/501 + return resFollow.getStatusCode() in INVALID_LOCATION_STATUS } /** diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt index f733198d67..8c6e7d1273 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt @@ -70,6 +70,24 @@ class CallGraphService { .filter { it.path == path } } + /** + * Resolve a raw path or URL (e.g. a Location header value, absolute or relative) + * against the RestPath templates declared in the schema. + * Returns the most specific matching template (fewest path parameters), or null if none match. + */ + fun resolveDeclaredPath(rawPathOrUrl: String): RestPath? { + val path = try { + java.net.URI(rawPathOrUrl).rawPath?.takeIf { it.isNotBlank() } ?: rawPathOrUrl + } catch (e: Exception) { + rawPathOrUrl + } + return endpointsInUse.asSequence() + .map { it.path } + .distinct() + .filter { it.matches(path) } + .minByOrNull { it.getParameterTokens().size } + } + /** * Check if the given endpoint(verb,path) is declared in the schema. * This is regardless of whether some endpoints were marked as ignored/to-skip 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 76a38db5cb..b51056160d 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 @@ -62,6 +62,9 @@ class HttpSemanticsService : TimeBoxedPhase{ @Inject private lateinit var epc: ExecutionPhaseController + @Inject + private lateinit var callGraphService: CallGraphService + /** * All actions that can be defined from the OpenAPI schema */ @@ -713,7 +716,16 @@ class HttpSemanticsService : TimeBoxedPhase{ */ private data class LocationCandidate( val individual: EvaluatedIndividual, - val sourceIndex: Int + val sourceIndex: Int, + val location: String + ) + + /** + * When the Location points to a path declared in the schema, the follow-up call + * uses the first declared verb following this priority order. + */ + private val locationFollowUpVerbPriority = listOf( + HttpVerb.GET, HttpVerb.DELETE, HttpVerb.POST, HttpVerb.PUT, HttpVerb.PATCH ) /** @@ -729,12 +741,24 @@ class HttpSemanticsService : TimeBoxedPhase{ val ea = evaluated[idx] ea.action as? RestCallAction ?: continue val r = ea.result as? RestCallResult ?: continue - if (r.getLocation().isNullOrBlank()) continue - candidates.add(LocationCandidate(ei, idx)) + val location = r.getLocation() + if (location.isNullOrBlank()) continue + candidates.add(LocationCandidate(ei, idx, location)) } return candidates } + /** + * Verb to use for the Location follow-up call. If the Location points to a path + * declared in the schema, use its declared verbs by [locationFollowUpVerbPriority]; + * otherwise default to GET. + */ + private fun followUpVerbForLocation(location: String): HttpVerb { + val declaredPath = callGraphService.resolveDeclaredPath(location) ?: return HttpVerb.GET + val verbs = callGraphService.endpointsForPath(declaredPath).map { it.verb }.toSet() + return locationFollowUpVerbPriority.firstOrNull { verbs.contains(it) } ?: HttpVerb.GET + } + private fun invalidLocation() { val candidates = individualsInSolution.asSequence() @@ -755,31 +779,35 @@ class HttpSemanticsService : TimeBoxedPhase{ ) val creator = ind.seeMainExecutableActions().last() + // If the Location points to a schema path, probe it with its most appropriate + // declared verb; otherwise fall back to GET. + val verb = followUpVerbForLocation(candidate.location) + // runtime URL is resolved from the Location header // (relative/absolute, possibly with query params). We do not bind to a schema // The path here is a structural placeholder; the real URL comes from chainState. - val getAction = RestCallAction( - id = "GET:LOCATION-FOLLOWUP", - verb = HttpVerb.GET, + val followUp = RestCallAction( + id = "$verb:LOCATION-FOLLOWUP", + verb = verb, path = RestPath("/"), parameters = mutableListOf(), auth = creator.auth ) - getAction.doInitialize(randomness) - getAction.forceNewTaints() + followUp.doInitialize(randomness) + followUp.forceNewTaints() try { // TODO: RestCallAction.creationLocationId() currently restricts location-id generation // to POST/PUT and throws otherwise, so this branch silently no-ops on other verbs. // After that restriction is refactored to allow any verb whose response carried a // Location header, this catch can be dropped and the oracle will fire for all verbs. - creator.saveAndLinkLocationTo(getAction) + creator.saveAndLinkLocationTo(followUp) } catch (e: IllegalArgumentException) { continue } - // add getAction as a last operation - ind.addMainActionInEmptyEnterpriseGroup(-1, getAction) + // add the follow-up as a last operation + ind.addMainActionInEmptyEnterpriseGroup(-1, followUp) prepareEvaluateAndSave(ind) }