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,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<String>) {
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<Any> {
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<Any> {
return ResponseEntity.status(404).build()
}
}
Original file line number Diff line number Diff line change
@@ -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<String>) {
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<Any> =
ResponseEntity.status(201).header("Location", "/api/a/$id/child").build()

@GetMapping(path = ["/api/a/{id}/child"])
open fun childA(@PathVariable("id") id: String): ResponseEntity<Any> =
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<Any> =
ResponseEntity.status(201).header("Location", "/api/b/$id/child").build()

@GetMapping(path = ["/api/b/{id}/child"])
open fun childB(@PathVariable("id") id: String): ResponseEntity<Any> =
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<Any> =
ResponseEntity.status(201).header("Location", "/api/c/$id/child").build()

@PutMapping(path = ["/api/c/{id}/child"])
open fun putChildC(@PathVariable("id") id: String): ResponseEntity<Any> =
ResponseEntity.status(405).build()

@PatchMapping(path = ["/api/c/{id}/child"])
open fun patchChildC(@PathVariable("id") id: String): ResponseEntity<Any> =
ResponseEntity.status(405).build()
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<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)

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will not work as soon as we introduce the new verb QUERY.
add a TODO stating to update when QUERY is going to be added

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,
Expand Down Expand Up @@ -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,
Expand All @@ -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?
Expand All @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading