blob: a1b67ec996c5c269faee602b8ef4515f99e88890 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.openwhisk.core.controller.test
import java.time.Instant
import java.util.Base64
import scala.concurrent.Future
import scala.concurrent.duration.FiniteDuration
import org.junit.runner.RunWith
import org.scalatest.{BeforeAndAfterEach, FlatSpec, Matchers}
import org.scalatest.junit.JUnitRunner
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import akka.http.scaladsl.model.FormData
import akka.http.scaladsl.model.HttpEntity
import akka.http.scaladsl.model.MediaTypes
import akka.http.scaladsl.model.StatusCodes._
import akka.http.scaladsl.model.HttpCharsets
import akka.http.scaladsl.model.HttpHeader
import akka.http.scaladsl.model.HttpResponse
import akka.http.scaladsl.model.Uri.Query
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.model.HttpMethods
import akka.http.scaladsl.model.headers.{`Access-Control-Request-Headers`, `Content-Type`, RawHeader}
import akka.http.scaladsl.model.ContentTypes
import akka.http.scaladsl.model.ContentType
import akka.http.scaladsl.model.MediaType
import spray.json._
import spray.json.DefaultJsonProtocol._
import org.apache.openwhisk.common.TransactionId
import org.apache.openwhisk.core.WhiskConfig
import org.apache.openwhisk.core.controller._
import org.apache.openwhisk.core.entitlement.EntitlementProvider
import org.apache.openwhisk.core.entitlement.Privilege
import org.apache.openwhisk.core.entitlement.Resource
import org.apache.openwhisk.core.entity._
import org.apache.openwhisk.core.entity.size._
import org.apache.openwhisk.core.loadBalancer.LoadBalancer
import org.apache.openwhisk.http.ErrorResponse
import org.apache.openwhisk.http.Messages
import scala.collection.immutable.Set
/**
* Tests web actions API.
*
* Unit tests of the controller service as a standalone component.
* These tests exercise a fresh instance of the service object in memory -- these
* tests do NOT communication with a whisk deployment.
*
* @Idioglossia
* "using Specification DSL to write unit tests, as in should, must, not, be"
* "using Specs2RouteTest DSL to chain HTTP requests for unit testing, as in ~>"
*/
@RunWith(classOf[JUnitRunner])
class WebActionsApiCommonTests extends FlatSpec with Matchers {
"extension splitter" should "split action name and extension" in {
Seq(".http", ".json", ".text", ".html", ".svg").foreach { ext =>
Seq(s"t$ext", s"tt$ext", s"t.wxyz$ext", s"tt.wxyz$ext").foreach { s =>
Seq(true, false).foreach { enforce =>
val (n, e) = WhiskWebActionsApi.mediaTranscoderForName(s, enforce)
val i = s.lastIndexOf(".")
n shouldBe s.substring(0, i)
e.get.extension shouldBe ext
}
}
}
Seq(s"t", "tt", "abcde", "abcdef", "t.wxyz").foreach { s =>
val (n, e) = WhiskWebActionsApi.mediaTranscoderForName(s, false)
n shouldBe s
e.get.extension shouldBe ".http"
}
Seq(s"t", "tt", "abcde", "abcdef", "t.wxyz").foreach { s =>
val (n, e) = WhiskWebActionsApi.mediaTranscoderForName(s, true)
n shouldBe s
e shouldBe empty
}
}
}
@RunWith(classOf[JUnitRunner])
class WebActionsApiTests extends FlatSpec with Matchers with WebActionsApiBaseTests {
override lazy val webInvokePathSegments = Seq("web")
override lazy val webApiDirectives = new WebApiDirectives()
"properties" should "match verion" in {
webApiDirectives.method shouldBe "__ow_method"
webApiDirectives.headers shouldBe "__ow_headers"
webApiDirectives.path shouldBe "__ow_path"
webApiDirectives.namespace shouldBe "__ow_user"
webApiDirectives.query shouldBe "__ow_query"
webApiDirectives.body shouldBe "__ow_body"
webApiDirectives.statusCode shouldBe "statusCode"
webApiDirectives.enforceExtension shouldBe false
webApiDirectives.reservedProperties shouldBe {
Set("__ow_method", "__ow_headers", "__ow_path", "__ow_user", "__ow_query", "__ow_body")
}
}
}
trait WebActionsApiBaseTests extends ControllerTestCommon with BeforeAndAfterEach with WhiskWebActionsApi {
val uuid = UUID()
val systemId = Subject()
val systemKey = BasicAuthenticationAuthKey(uuid, Secret())
val systemIdentity =
Future.successful(
Identity(systemId, Namespace(EntityName(systemId.asString), uuid), systemKey, rights = Privilege.ALL))
val namespace = EntityPath(systemId.asString)
val proxyNamespace = namespace.addPath(EntityName("proxy"))
override lazy val entitlementProvider = new TestingEntitlementProvider(whiskConfig, loadBalancer)
protected val testRoutePath = webInvokePathSegments.mkString("/", "/", "")
def aname() = MakeName.next("web_action_tests")
behavior of "Web actions API"
var failActivation = 0 // toggle to cause action to fail
var failThrottleForSubject: Option[Subject] = None // toggle to cause throttle to fail for subject
var failCheckEntitlement = false // toggle to cause entitlement to fail
var actionResult: Option[JsObject] = None
var testParametersInInvokeAction = true // toggle to test parameter in invokeAction
var requireAuthenticationKey = "example-web-action-api-key"
var invocationCount = 0
var invocationsAllowed = 0
lazy val testFixturesToGc = {
implicit val tid = transid()
Seq(
stubPackage,
stubAction(namespace, EntityName("export_c")),
stubAction(proxyNamespace, EntityName("export_c")),
stubAction(proxyNamespace, EntityName("raw_export_c"))).map { f =>
put(entityStore, f, garbageCollect = false)
}
}
override def beforeAll() = {
testFixturesToGc.foreach(f => ())
}
override def beforeEach() = {
invocationCount = 0
invocationsAllowed = 0
}
override def afterEach() = {
failActivation = 0
failThrottleForSubject = None
failCheckEntitlement = false
actionResult = None
testParametersInInvokeAction = true
assert(invocationsAllowed == invocationCount, "allowed invoke count did not match actual")
cleanup()
}
override def afterAll() = {
implicit val tid = transid()
testFixturesToGc.foreach(delete(entityStore, _))
}
val allowedMethodsWithEntity = {
val nonModifierMethods = Seq(Get, Options)
val modifierMethods = Seq(Post, Put, Delete, Patch)
modifierMethods ++ nonModifierMethods
}
val allowedMethods = {
allowedMethodsWithEntity ++ Seq(Head)
}
val pngSample = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAA/klEQVQYGWNgAAEHBxaG//+ZQMyyn581Pfas+cRQnf1LfF" +
"Ljf+62smUgcUbt0FA2Zh7drf/ffMy9vLn3RurrW9e5hCU11i2azfD4zu1/DHz8TAy/foUxsXBrFzHzC7r8+M9S1vn1qxQT07dDjL" +
"9fdemrqKxlYGT6z8AIMo6hgeUfA0PUvy9fGFh5GWK3z7vNxSWt++jX99+8SoyiGQwsW38w8PJEM7x5v5SJ8f+/xv8MDAzffv9hev" +
"fkWjiXBGMpMx+j2awovjcMjFztDO8+7GF49LkbZDCDeXLTWnZO7qDfn1/+5jbw/8pjYWS4wZLztXnuEuYTk2M+MzIw/AcA36Vewa" +
"D6fzsAAAAASUVORK5CYII="
// there is only one package that is predefined 'proxy'
val stubPackage = WhiskPackage(
EntityPath(systemId.asString),
EntityName("proxy"),
parameters = Parameters("x", JsString("X")) ++ Parameters("z", JsString("z")))
val packages = Seq(stubPackage)
val defaultActionParameters = {
Parameters("y", JsString("Y")) ++ Parameters("z", JsString("Z")) ++ Parameters("empty", JsNull)
}
// action names that start with 'export_' will automatically have an web-export annotation added by the test harness
protected def stubAction(namespace: EntityPath,
name: EntityName,
customOptions: Boolean = true,
requireAuthentication: Boolean = false,
requireAuthenticationAsBoolean: Boolean = true) = {
val annotations = Parameters(Annotations.FinalParamsAnnotationName, JsTrue)
WhiskAction(
namespace,
name,
jsDefault("??"),
defaultActionParameters,
annotations = {
if (name.asString.startsWith("export_")) {
annotations ++
Parameters("web-export", JsTrue) ++ {
if (requireAuthentication) {
Parameters(
"require-whisk-auth",
(if (requireAuthenticationAsBoolean) JsTrue else JsString(requireAuthenticationKey)))
} else Parameters()
} ++ {
if (customOptions) {
Parameters("web-custom-options", JsTrue)
} else Parameters()
}
} else if (name.asString.startsWith("raw_export_")) {
annotations ++
Parameters("web-export", JsTrue) ++
Parameters("raw-http", JsTrue) ++ {
if (requireAuthentication) {
Parameters(
"require-whisk-auth",
(if (requireAuthenticationAsBoolean) JsTrue else JsString(requireAuthenticationKey)))
} else Parameters()
} ++ {
if (customOptions) {
Parameters("web-custom-options", JsTrue)
} else Parameters()
}
} else annotations
})
}
// there is only one identity defined for the fully qualified name of the web action: 'systemId'
override protected def getIdentity(namespace: EntityName)(implicit transid: TransactionId): Future[Identity] = {
if (namespace.asString == systemId.asString) {
systemIdentity
} else {
logging.info(this, s"namespace has no identity")
Future.failed(RejectRequest(BadRequest))
}
}
override protected[controller] def invokeAction(
user: Identity,
action: WhiskActionMetaData,
payload: Option[JsObject],
waitForResponse: Option[FiniteDuration],
cause: Option[ActivationId])(implicit transid: TransactionId): Future[Either[ActivationId, WhiskActivation]] = {
invocationCount = invocationCount + 1
if (failActivation == 0) {
// construct a result stub that includes:
// 1. the package name for the action (to confirm that this resolved to systemId)
// 2. the action name (to confirm that this resolved to the expected action)
// 3. the payload received by the action which consists of the action.params + payload
val result = actionResult getOrElse JsObject(
"pkg" -> action.namespace.toJson,
"action" -> action.name.toJson,
"content" -> action.parameters.merge(payload).get)
val activation = WhiskActivation(
action.namespace,
action.name,
user.subject,
ActivationId.generate(),
start = Instant.now,
end = Instant.now,
response = {
actionResult.flatMap { r =>
r.fields.get("application_error").map { e =>
ActivationResponse.applicationError(e)
} orElse r.fields.get("developer_error").map { e =>
ActivationResponse.developerError(e, None)
} orElse r.fields.get("whisk_error").map { e =>
ActivationResponse.whiskError(e)
} orElse None // for clarity
} getOrElse ActivationResponse.success(Some(result))
})
// check that action parameters were merged with package
// all actions have default parameters (see stubAction)
if (testParametersInInvokeAction) {
if (!action.namespace.defaultPackage) {
action.parameters shouldBe (stubPackage.parameters ++ defaultActionParameters)
} else {
action.parameters shouldBe defaultActionParameters
}
action.parameters.get("z") shouldBe defaultActionParameters.get("z")
}
Future.successful(Right(activation))
} else if (failActivation == 1) {
Future.successful(Left(ActivationId.generate()))
} else {
Future.failed(new IllegalStateException("bad activation"))
}
}
def metaPayload(method: String,
params: JsObject,
identity: Option[Identity],
path: String = "",
body: Option[JsObject] = None,
pkgName: String = null,
headers: List[HttpHeader] = List.empty) = {
val packageActionParams = Option(pkgName)
.filter(_ != null)
.flatMap(n => packages.find(_.name == EntityName(n)))
.map(_.parameters)
.getOrElse(Parameters())
(packageActionParams ++ defaultActionParameters).merge {
Some {
JsObject(
params.fields ++
body.map(_.fields).getOrElse(Map.empty) ++
Context(webApiDirectives, HttpMethods.getForKey(method.toUpperCase).get, headers, path, Query.Empty)
.metadata(identity))
}
}.get
}
def confirmErrorWithTid(error: JsObject, message: Option[String] = None) = {
error.fields.size shouldBe 2
error.fields.get("error") shouldBe defined
message.foreach { m =>
error.fields.get("error").get shouldBe JsString(m)
}
error.fields.get("code") shouldBe defined
error.fields.get("code").get shouldBe an[JsString]
}
Seq(None, Some(WhiskAuthHelpers.newIdentity())).foreach { creds =>
it should s"not match invalid routes (auth? ${creds.isDefined})" in {
implicit val tid = transid()
// none of these should match a route
Seq("a", "a/b", "/a", s"$systemId/c", s"$systemId/export_c").foreach { path =>
allowedMethods.foreach { m =>
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(NotFound)
}
}
}
}
it should s"reject requests when Identity, package or action lookup fail or missing annotation (auth? ${creds.isDefined})" in {
implicit val tid = transid()
put(entityStore, stubAction(namespace, EntityName("c")))
// the first of these fails in the identity lookup,
// the second in the package lookup (does not exist),
// the third fails the annotation check (no web-export annotation because name doesn't start with export_c)
// the fourth fails the action lookup
Seq("guest/proxy/c", s"$systemId/doesnotexist/c", s"$systemId/default/c", s"$systemId/proxy/export_fail")
.foreach { path =>
allowedMethods.foreach { m =>
m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
status should be(NotFound)
}
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
if (webApiDirectives.enforceExtension) {
status should be(NotAcceptable)
confirmErrorWithTid(
responseAs[JsObject],
Some(Messages.contentTypeExtensionNotSupported(WhiskWebActionsApi.allowedExtensions)))
} else {
status should be(NotFound)
}
}
}
}
}
it should s"reject requests when whisk authentication is required but none given (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val entityName = MakeName.next("export")
val action =
stubAction(
proxyNamespace,
entityName,
customOptions = false,
requireAuthentication = true,
requireAuthenticationAsBoolean = true)
val path = action.fullyQualifiedName(false)
put(entityStore, action)
allowedMethods.foreach { m =>
m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
if (m === Options) {
status should be(OK) // options response is always present regardless of auth
header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
} else if (creds.isEmpty) {
status should be(Unauthorized) // if user is not authenticated, reject all requests
} else {
invocationsAllowed += 1
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> entityName.asString.toJson,
"content" -> metaPayload(m.method.name.toLowerCase, JsObject.empty, creds, pkgName = "proxy"))
response
.fields("content")
.asJsObject
.fields(webApiDirectives.namespace) shouldBe creds.get.namespace.name.toJson
}
}
}
}
it should s"reject requests when x-authentication is required but none given (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val entityName = MakeName.next("export")
val action =
stubAction(
proxyNamespace,
entityName,
customOptions = false,
requireAuthentication = true,
requireAuthenticationAsBoolean = false)
val path = action.fullyQualifiedName(false)
put(entityStore, action)
allowedMethods.foreach { m =>
// web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value does not match
m(s"$testRoutePath/${path}.json") ~> addHeader(
WhiskAction.requireWhiskAuthHeader,
requireAuthenticationKey + "-bad") ~> Route
.seal(routes(creds)) ~> check {
if (m == Options) {
status should be(OK) // options should always respond
header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
} else {
status should be(Unauthorized)
}
}
// web action require-whisk-auth is set, but the header X-Require-Whisk-Auth value is not set
m(s"$testRoutePath/${path}.json") ~> Route.seal(routes(creds)) ~> check {
if (m == Options) {
status should be(OK) // options should always respond
header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
} else {
status should be(Unauthorized)
}
}
m(s"$testRoutePath/${path}.json") ~> addHeader(WhiskAction.requireWhiskAuthHeader, requireAuthenticationKey) ~> Route
.seal(routes(creds)) ~> check {
if (m == Options) {
status should be(OK) // options should always respond
header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
} else {
invocationsAllowed += 1
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> entityName.asString.toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
JsObject.empty,
creds,
pkgName = "proxy",
headers = List(RawHeader(WhiskAction.requireWhiskAuthHeader, requireAuthenticationKey))))
if (creds.isDefined) {
response
.fields("content")
.asJsObject
.fields(webApiDirectives.namespace) shouldBe creds.get.namespace.name.toJson
}
}
}
}
}
it should s"invoke action that times out and provide a code (auth? ${creds.isDefined})" in {
implicit val tid = transid()
failActivation = 1
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$systemId/proxy/export_c.json") ~> Route.seal(routes(creds)) ~> check {
status should be(Accepted)
val response = responseAs[JsObject]
confirmErrorWithTid(response, Some("Response not yet ready."))
}
}
}
it should s"invoke action that errors and respond with error and code (auth? ${creds.isDefined})" in {
implicit val tid = transid()
failActivation = 2
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$systemId/proxy/export_c.json") ~> Route.seal(routes(creds)) ~> check {
status should be(InternalServerError)
val response = responseAs[JsObject]
confirmErrorWithTid(response)
}
}
}
it should s"invoke action and merge query parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json?a=b&c=d").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
Map("a" -> "b", "c" -> "d").toJson.asJsObject,
creds,
pkgName = "proxy"))
}
}
}
}
it should s"invoke action and merge body parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
// both of these should produce full result objects (trailing slash is ok)
Seq(s"$systemId/proxy/export_c.json", s"$systemId/proxy/export_c.json/").foreach { path =>
allowedMethodsWithEntity.foreach { m =>
val content = JsObject("extra" -> "read all about it".toJson, "yummy" -> true.toJson)
val p = if (path.endsWith("/")) "/" else ""
invocationsAllowed += 1
m(s"$testRoutePath/$path", content) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
JsObject.empty,
creds,
body = Some(content),
path = p,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
}
}
it should s"invoke action which receives an empty entity (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq("", JsArray.empty.compactPrint, JsObject.empty.compactPrint, JsNull.compactPrint).foreach { arg =>
Seq(s"$systemId/proxy/export_c.json").foreach { path =>
allowedMethodsWithEntity.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path", HttpEntity(ContentTypes.`application/json`, arg)) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
if (arg.nonEmpty && arg != "{}") JsObject(webApiDirectives.body -> arg.parseJson) else JsObject.empty,
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
}
}
}
it should s"invoke action and merge query and body parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json?a=b&c=d").foreach { path =>
allowedMethodsWithEntity.foreach { m =>
val content = JsObject("extra" -> "read all about it".toJson, "yummy" -> true.toJson)
invocationsAllowed += 1
m(s"$testRoutePath/$path", content) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
Map("a" -> "b", "c" -> "d").toJson.asJsObject,
creds,
body = Some(content),
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
}
}
it should s"invoke action in default package (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/default/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(m.method.name.toLowerCase, JsObject.empty, creds))
}
}
}
}
it should s"invoke action in a binding of private package (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath(systemId.asString), aname(), None, stubPackage.parameters)
val reference = WhiskPackage(EntityPath(systemId.asString), aname(), provider.bind)
val action = stubAction(provider.fullPath, EntityName("export_c"))
put(entityStore, provider)
put(entityStore, reference)
put(entityStore, action)
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
}
}
}
}
it should s"invoke action in a binding of public package (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath("guest"), aname(), None, stubPackage.parameters, publish = true)
val reference = WhiskPackage(EntityPath(systemId.asString), aname(), provider.bind)
val action = stubAction(provider.fullPath, EntityName("export_c"))
put(entityStore, provider)
put(entityStore, reference)
put(entityStore, action)
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
}
}
}
}
it should s"invoke action relative to a binding where the action doesn't exist (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath("guest"), aname(), None, stubPackage.parameters, publish = true)
val reference = WhiskPackage(EntityPath(systemId.asString), aname(), provider.bind)
put(entityStore, provider)
put(entityStore, reference)
// action is not created
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(NotFound)
}
}
}
}
it should s"invoke action in non-existing binding (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath("guest"), aname(), None, stubPackage.parameters, publish = true)
val action = stubAction(provider.fullPath, EntityName("export_c"))
val reference = WhiskPackage(EntityPath(systemId.asString), aname(), provider.bind)
put(entityStore, provider)
put(entityStore, action)
// reference is not created
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(NotFound)
}
}
}
}
it should s"not inherit annotations of package binding (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath("guest"), aname(), None, stubPackage.parameters, publish = true)
val reference = WhiskPackage(
EntityPath(systemId.asString),
aname(),
provider.bind,
annotations = Parameters("web-export", JsFalse))
val action = stubAction(provider.fullPath, EntityName("export_c"))
put(entityStore, provider)
put(entityStore, reference)
put(entityStore, action)
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
}
}
}
}
it should s"reject request that tries to override final parameters of action in package binding (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val provider = WhiskPackage(EntityPath("guest"), aname(), None, publish = true)
val reference = WhiskPackage(EntityPath(systemId.asString), aname(), provider.bind, stubPackage.parameters)
val action = stubAction(provider.fullPath, EntityName("export_c"))
put(entityStore, provider)
put(entityStore, reference)
put(entityStore, action)
val contentX = JsObject("x" -> "overridden".toJson)
val contentZ = JsObject("z" -> "overridden".toJson)
allowedMethodsWithEntity.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$systemId/${reference.name}/export_c.json?x=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/${reference.name}/export_c.json?y=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/${reference.name}/export_c.json", contentX) ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/${reference.name}/export_c.json?y=overridden", contentZ) ~> Route.seal(
routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/${reference.name}/export_c.json?empty=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"guest/${provider.name}".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
Map("empty" -> "overridden").toJson.asJsObject,
creds,
pkgName = "proxy"))
}
}
}
it should s"match precedence order for merging parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
testParametersInInvokeAction = false
val provider = WhiskPackage(
EntityPath("guest"),
aname(),
None,
Parameters("a", JsString("A")) ++ Parameters("b", JsString("b")),
publish = true)
val reference = WhiskPackage(
EntityPath(systemId.asString),
aname(),
provider.bind,
Parameters("a", JsString("a")) ++ Parameters("c", JsString("c")))
// stub action has defaultActionParameters
val action = stubAction(provider.fullPath, EntityName("export_c"))
put(entityStore, provider)
put(entityStore, reference)
put(entityStore, action)
Seq(s"$systemId/${reference.name}/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"guest/${provider.name}".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
Map("a" -> "a", "b" -> "b", "c" -> "c").toJson.asJsObject,
creds))
}
}
}
}
it should s"pass the unmatched segment to the action (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json/content").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject].fields("content")
response shouldBe metaPayload(
m.method.name.toLowerCase,
JsObject.empty,
creds,
path = "/content",
pkgName = "proxy")
}
}
}
}
it should s"respond with error when expected text property does not exist (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.text").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(NotFound)
confirmErrorWithTid(responseAs[JsObject], Some(Messages.propertyNotFound))
// ensure that error message is pretty printed as { error, code }
responseAs[String].linesIterator should have size 4
}
}
}
}
it should s"use action status code and headers to terminate an http response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
actionResult = Some(
JsObject(
"headers" -> JsObject("location" -> "http://openwhisk.org".toJson),
webApiDirectives.statusCode -> Found.intValue.toJson))
invocationsAllowed += 1
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(Found)
header("location").get.toString shouldBe "location: http://openwhisk.org"
}
}
}
}
it should s"use default field projection for extension (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("location" -> "http://openwhisk.org".toJson),
webApiDirectives.statusCode -> Found.intValue.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(Found)
header("location").get.toString shouldBe "location: http://openwhisk.org"
}
}
}
Seq(s"$systemId/proxy/export_c.text").foreach { path =>
allowedMethods.foreach { m =>
val text = "default text"
invocationsAllowed += 1
actionResult = Some(JsObject("text" -> JsString(text)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
contentType shouldBe MediaTypes.`text/plain`.withCharset(HttpCharsets.`UTF-8`)
val response = responseAs[String]
response shouldBe text
}
}
}
Seq(s"$systemId/proxy/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject("foobar" -> JsString("foobar")))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe actionResult.get
// ensure response is pretty printed
responseAs[String] shouldBe {
"""{
| "foobar": "foobar"
|}""".stripMargin
}
}
}
}
Seq(s"$systemId/proxy/export_c.html").foreach { path =>
allowedMethods.foreach { m =>
val html = "<html>hi</htlml>"
invocationsAllowed += 1
actionResult = Some(JsObject("html" -> JsString(html)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
contentType shouldBe MediaTypes.`text/html`.withCharset(HttpCharsets.`UTF-8`)
val response = responseAs[String]
response shouldBe html
}
}
}
Seq(s"$systemId/proxy/export_c.svg").foreach { path =>
allowedMethods.foreach { m =>
val svg = """<svg><circle cx="3" cy="3" r="3" fill="blue"/></svg>"""
invocationsAllowed += 1
actionResult = Some(JsObject("svg" -> JsString(svg)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
//contentType shouldBe MediaTypes.`image/svg+xml`.withCharset(HttpCharsets.`UTF-8`)
val response = responseAs[String]
response shouldBe svg
}
}
}
}
it should s"handle http web action and provide defaults (auth? ${creds.isDefined})" in {
implicit val tid = transid()
def confirmEmptyResponse() = {
status should be(NoContent)
response.entity shouldBe HttpEntity.Empty
withClue(headers) {
headers.length shouldBe 1
headers.exists(_.is(ActivationIdHeader)) should be(true)
}
}
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
Set(JsObject.empty, JsObject("body" -> "".toJson), JsObject("body" -> JsNull)).foreach { bodyResult =>
allowedMethods.foreach { m =>
invocationsAllowed += 2
actionResult = Some(bodyResult)
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
withClue(s"failed for: $bodyResult") {
confirmEmptyResponse()
}
}
// repeat with accept header, which should be ignored for content-negotiation
m(s"$testRoutePath/$path") ~> addHeader("Accept", "application/json") ~> Route.seal(routes(creds)) ~> check {
withClue(s"with accept header, failed for: $bodyResult") {
confirmEmptyResponse()
}
}
}
}
}
}
it should s"handle all JSON values with .text extension (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(JsObject("a" -> "A".toJson), JsArray("a".toJson), JsString("a"), JsTrue, JsNumber(1), JsNull)
.foreach { jsval =>
val path = s"$systemId/proxy/export_c.text"
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject("body" -> jsval))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
responseAs[String] shouldBe {
jsval match {
case _: JsObject => jsval.prettyPrint
case _: JsArray => jsval.prettyPrint
case JsString(s) => s
case JsBoolean(b) => b.toString
case JsNumber(n) => n.toString
case _ => "null"
}
}
}
}
}
}
it should s"handle http web action with JSON object as string response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
Seq(OK, Created).foreach { statusCode =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("content-type" -> "application/json".toJson),
webApiDirectives.statusCode -> statusCode.intValue.toJson,
"body" -> JsObject("field" -> "value".toJson).compactPrint.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(statusCode)
mediaType shouldBe MediaTypes.`application/json`
responseAs[JsObject] shouldBe JsObject("field" -> "value".toJson)
}
}
}
}
}
it should s"handle http web action with partially specified result (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
// omit status code
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("content-type" -> "application/json".toJson),
"body" -> JsObject("field" -> "value".toJson)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[JsObject] shouldBe JsObject("field" -> "value".toJson)
}
}
// omit status code and headers
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject("body" -> JsObject("field" -> "value".toJson).compactPrint.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[String] shouldBe actionResult.get.fields("body").convertTo[String]
contentType shouldBe MediaTypes.`text/html`.withCharset(HttpCharsets.`UTF-8`)
}
}
// omit headers only
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
webApiDirectives.statusCode -> Created.intValue.toJson,
"body" -> JsObject("field" -> "value".toJson).compactPrint.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(Created)
responseAs[String] shouldBe actionResult.get.fields("body").convertTo[String]
contentType shouldBe MediaTypes.`text/html`.withCharset(HttpCharsets.`UTF-8`)
}
}
// omit body and headers
Seq(OK, Created, NoContent).foreach { statusCode =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject(webApiDirectives.statusCode -> statusCode.intValue.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(statusCode)
headers.size shouldBe 1
headers.exists(_.is(ActivationIdHeader)) should be(true)
response.entity shouldBe HttpEntity.Empty
}
}
}
// omit body but include headers
Seq(OK, Created, NoContent).foreach { statusCode =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("Set-Cookie" -> "a=b".toJson, "content-type" -> "application/json".toJson),
webApiDirectives.statusCode -> statusCode.intValue.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(statusCode)
headers should contain(RawHeader("Set-Cookie", "a=b"))
headers.exists(_.is(ActivationIdHeader)) should be(true)
response.entity shouldBe HttpEntity.Empty
}
}
}
}
}
it should s"handle http web action with no body when status code is set (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
// omit body and headers, but add accept header on the request
Seq(OK, Created, NoContent).foreach { statusCode =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject(webApiDirectives.statusCode -> statusCode.intValue.toJson))
m(s"$testRoutePath/$path") ~> addHeader("Accept", "application/json") ~> Route.seal(routes(creds)) ~> check {
status should be(statusCode)
headers.size shouldBe 1
headers.exists(_.is(ActivationIdHeader)) should be(true)
response.entity shouldBe HttpEntity.Empty
}
}
}
// omit body but include headers, and add accept header on the request
Seq(OK, Created, NoContent).foreach { statusCode =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("Set-Cookie" -> "a=b".toJson, "content-type" -> "application/json".toJson),
webApiDirectives.statusCode -> statusCode.intValue.toJson))
m(s"$testRoutePath/$path") ~> addHeader("Accept", "application/json") ~> Route.seal(routes(creds)) ~> check {
status should be(statusCode)
headers should contain(RawHeader("Set-Cookie", "a=b"))
headers.exists(_.is(ActivationIdHeader)) should be(true)
response.entity shouldBe HttpEntity.Empty
}
}
}
}
}
it should s"handle http web action with JSON object response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(
(JsObject("content-type" -> "application/json".toJson), OK),
(JsObject.empty, OK),
(JsObject("content-type" -> "text/html".toJson), BadRequest)).foreach {
case (headers, expectedCode) =>
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> headers,
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> JsObject("field" -> "value".toJson)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(expectedCode)
if (expectedCode == OK) {
header("content-type").map(_.toString shouldBe "content-type: application/json")
responseAs[JsObject] shouldBe JsObject("field" -> "value".toJson)
} else {
confirmErrorWithTid(responseAs[JsObject], Some(Messages.httpContentTypeError))
}
}
}
}
}
}
it should s"handle http web action with base64 encoded known '+json' response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("content-type" -> "application/json-patch+json".toJson),
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> Base64.getEncoder.encodeToString {
JsObject("field" -> "value".toJson).compactPrint.getBytes
}.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
mediaType.value shouldBe "application/json-patch+json"
responseAs[String].parseJson shouldBe JsObject("field" -> "value".toJson)
}
}
}
}
it should s"handle http web action for known '+json' response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(
(JsObject("content-type" -> "application/json-patch+json".toJson), OK),
(JsObject("content-type" -> "text/html".toJson), BadRequest)).foreach {
case (headers, expectedCode) =>
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> headers,
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> JsObject("field" -> "value".toJson)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(expectedCode)
if (expectedCode == OK) {
mediaType.value shouldBe "application/json-patch+json"
responseAs[String].parseJson shouldBe JsObject("field" -> "value".toJson)
} else {
confirmErrorWithTid(responseAs[JsObject], Some(Messages.httpContentTypeError))
}
}
}
}
}
}
it should s"handle http web action for unknown '+json' response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(
(JsObject("content-type" -> "application/hal+json".toJson), OK),
(JsObject("content-type" -> "text/html".toJson), BadRequest)).foreach {
case (headers, expectedCode) =>
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> headers,
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> JsObject("field" -> "value".toJson)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(expectedCode)
if (expectedCode == OK) {
mediaType.value shouldBe "application/hal+json"
responseAs[String].parseJson shouldBe JsObject("field" -> "value".toJson)
} else {
confirmErrorWithTid(responseAs[JsObject], Some(Messages.httpContentTypeError))
}
}
}
}
}
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject(webApiDirectives.statusCode -> OK.intValue.toJson, "body" -> JsNumber(3)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
header("content-type").map(_.toString shouldBe "content-type: application/json")
responseAs[String].toInt shouldBe 3
}
}
}
}
it should s"handle http web action with base64 encoded binary response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val expectedEntity = HttpEntity(ContentType(MediaTypes.`image/png`), Base64.getDecoder().decode(pngSample))
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject(`Content-Type`.lowercaseName -> MediaTypes.`image/png`.toString.toJson),
"body" -> pngSample.toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
response.entity shouldBe expectedEntity
}
}
}
}
it should s"handle http web action with html/text response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult =
Some(JsObject(webApiDirectives.statusCode -> OK.intValue.toJson, "body" -> "hello world".toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[String] shouldBe "hello world"
}
}
}
}
it should s"allow web action with incorrect application/json header and text response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("content-type" -> "application/json".toJson),
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> "hello world".toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
mediaType shouldBe MediaTypes.`application/json`
headers.size shouldBe 1
headers.exists(_.is(ActivationIdHeader)) should be(true)
responseAs[String] shouldBe "hello world"
}
}
}
}
it should s"reject http web action with invalid content-type header (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"headers" -> JsObject("content-type" -> "xyzbar".toJson),
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> "hello world".toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
confirmErrorWithTid(responseAs[JsObject], Some(Messages.httpUnknownContentType))
}
}
}
}
it should s"handle an activation that results in application error (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(
JsObject(
"application_error" -> JsObject(
webApiDirectives.statusCode -> OK.intValue.toJson,
"body" -> "no hello for you".toJson)))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[String] shouldBe "no hello for you"
}
}
}
}
it should s"handle an activation that results in application error that does not match .json extension (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json").foreach { path =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject("application_error" -> "bad response type".toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
confirmErrorWithTid(responseAs[JsObject], Some(Messages.invalidMedia(MediaTypes.`application/json`)))
}
}
}
}
it should s"handle an activation that results in developer or system error (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json", s"$systemId/proxy/export_c.text")
.foreach { path =>
Seq("developer_error", "whisk_error").foreach { e =>
allowedMethods.foreach { m =>
invocationsAllowed += 1
actionResult = Some(JsObject(e -> "bad response type".toJson))
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
if (e == "application_error") {
confirmErrorWithTid(responseAs[JsObject], Some(Messages.invalidMedia(MediaTypes.`application/json`)))
} else {
confirmErrorWithTid(responseAs[JsObject], Some(Messages.errorProcessingRequest))
}
}
}
}
}
}
it should s"support formdata (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json").foreach { path =>
val form = FormData(Map("field1" -> "value1", "field2" -> "value2"))
invocationsAllowed += 1
Post(s"$testRoutePath/$path", form.toEntity) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[JsObject].fields("content").asJsObject.fields("field1") shouldBe JsString("value1")
responseAs[JsObject].fields("content").asJsObject.fields("field2") shouldBe JsString("value2")
}
}
}
it should s"reject requests when entity size exceeds allowed limit (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.json").foreach { path =>
val largeEntity = "a" * (allowedActivationEntitySize.toInt + 1)
val content = s"""{"a":"$largeEntity"}"""
Post(s"$testRoutePath/$path", content.parseJson.asJsObject) ~> Route.seal(routes(creds)) ~> check {
status should be(PayloadTooLarge)
val expectedErrorMsg = Messages.entityTooBig(
SizeError(fieldDescriptionForSizeError, (largeEntity.length + 8).B, allowedActivationEntitySize.B))
confirmErrorWithTid(responseAs[JsObject], Some(expectedErrorMsg))
}
val form = FormData(Map("a" -> largeEntity))
Post(s"$testRoutePath/$path", form) ~> Route.seal(routes(creds)) ~> check {
status should be(PayloadTooLarge)
val expectedErrorMsg = Messages.entityTooBig(
SizeError(fieldDescriptionForSizeError, (largeEntity.length + 2).B, allowedActivationEntitySize.B))
confirmErrorWithTid(responseAs[JsObject], Some(expectedErrorMsg))
}
}
}
it should s"reject unknown extensions (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(
s"$systemId/proxy/export_c.xyz",
s"$systemId/proxy/export_c.xyz/",
s"$systemId/proxy/export_c.xyz/content",
s"$systemId/proxy/export_c.xyzz",
s"$systemId/proxy/export_c.xyzz/",
s"$systemId/proxy/export_c.xyzz/content").foreach { path =>
allowedMethods.foreach { m =>
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
if (webApiDirectives.enforceExtension) {
status should be(NotAcceptable)
confirmErrorWithTid(
responseAs[JsObject],
Some(Messages.contentTypeExtensionNotSupported(WhiskWebActionsApi.allowedExtensions)))
} else {
status should be(NotFound)
}
}
}
}
}
it should s"reject request that tries to override reserved properties (auth? ${creds.isDefined})" in {
implicit val tid = transid()
allowedMethodsWithEntity.foreach { m =>
webApiDirectives.reservedProperties.foreach { p =>
m(s"$testRoutePath/$systemId/proxy/export_c.json?$p=YYY") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/proxy/export_c.json", JsObject(p -> "YYY".toJson)) ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
}
}
}
it should s"reject request that tries to override final parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val contentX = JsObject("x" -> "overridden".toJson)
val contentZ = JsObject("z" -> "overridden".toJson)
allowedMethodsWithEntity.foreach { m =>
invocationsAllowed += 1
m(s"$testRoutePath/$systemId/proxy/export_c.json?x=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/proxy/export_c.json?y=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/proxy/export_c.json", contentX) ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/proxy/export_c.json?y=overridden", contentZ) ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
responseAs[ErrorResponse].error shouldBe Messages.parametersNotAllowed
}
m(s"$testRoutePath/$systemId/proxy/export_c.json?empty=overridden") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
m.method.name.toLowerCase,
Map("empty" -> "overridden").toJson.asJsObject,
creds,
pkgName = "proxy"))
}
}
}
it should s"inline body when receiving entity that is not a JsObject (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val str = "1,2,3"
invocationsAllowed = 3
Post(s"$testRoutePath/$systemId/proxy/export_c.json", HttpEntity(ContentTypes.`text/html(UTF-8)`, str)) ~> Route
.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
JsObject(webApiDirectives.body -> str.toJson),
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`text/html(UTF-8)`))))
}
Post(s"$testRoutePath/$systemId/proxy/export_c.json?a=b&c=d") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map("a" -> "b", "c" -> "d").toJson.asJsObject,
creds,
pkgName = "proxy"))
}
Post(s"$testRoutePath/$systemId/proxy/export_c.json?a=b&c=d", JsObject.empty) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map("a" -> "b", "c" -> "d").toJson.asJsObject,
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
it should s"throttle subject owning namespace for web action (auth? ${creds.isDefined})" in {
implicit val tid = transid()
// this should fail for exceeding quota
Seq(s"$systemId/proxy/export_c.text/content/z").foreach { path =>
allowedMethods.foreach { m =>
failThrottleForSubject = Some(systemId)
m(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(TooManyRequests)
confirmErrorWithTid(responseAs[JsObject], Some(Messages.tooManyRequests(2, 1)))
}
failThrottleForSubject = None
}
}
}
it should s"respond with custom options (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
invocationsAllowed += 1 // custom options means action is invoked
actionResult =
Some(JsObject("headers" -> JsObject("Access-Control-Allow-Methods" -> "OPTIONS, GET, PATCH".toJson)))
// the added headers should be ignored
Options(s"$testRoutePath/$path") ~> addHeader(`Access-Control-Request-Headers`("x-custom-header")) ~> Route
.seal(routes(creds)) ~> check {
header("Access-Control-Allow-Origin") shouldBe empty
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
}
}
}
it should s"respond with custom options even when authentication is required but missing (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val entityName = MakeName.next("export")
val action =
stubAction(
proxyNamespace,
entityName,
customOptions = true,
requireAuthentication = true,
requireAuthenticationAsBoolean = true)
val path = action.fullyQualifiedName(false)
put(entityStore, action)
invocationsAllowed += 1 // custom options means action is invoked
actionResult =
Some(JsObject("headers" -> JsObject("Access-Control-Allow-Methods" -> "OPTIONS, GET, PATCH".toJson)))
// the added headers should be ignored
Options(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
header("Access-Control-Allow-Origin") shouldBe empty
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, PATCH"
header("Access-Control-Request-Headers") shouldBe empty
}
}
it should s"support multiple values for headers (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
invocationsAllowed += 1
actionResult =
Some(JsObject("headers" -> JsObject("Set-Cookie" -> JsArray(JsString("a=b"), JsString("c=d; Path = /")))))
Options(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
headers should contain allOf (RawHeader("Set-Cookie", "a=b"),
RawHeader("Set-Cookie", "c=d; Path = /"))
}
}
}
it should s"invoke action and respond with default options headers (auth? ${creds.isDefined})" in {
implicit val tid = transid()
put(entityStore, stubAction(proxyNamespace, EntityName("export_without_custom_options"), false))
Seq(s"$systemId/proxy/export_without_custom_options.http", s"$systemId/proxy/export_without_custom_options.json")
.foreach { path =>
Seq(`Access-Control-Request-Headers`("x-custom-header"), RawHeader("x-custom-header", "value")).foreach {
testHeader =>
allowedMethods.foreach { m =>
if (m != Options) invocationsAllowed += 1 // options verb does not invoke an action
m(s"$testRoutePath/$path") ~> addHeader(testHeader) ~> Route.seal(routes(creds)) ~> check {
header("Access-Control-Allow-Origin").get.toString shouldBe "Access-Control-Allow-Origin: *"
header("Access-Control-Allow-Methods").get.toString shouldBe "Access-Control-Allow-Methods: OPTIONS, GET, DELETE, POST, PUT, HEAD, PATCH"
if (testHeader.name == `Access-Control-Request-Headers`.name) {
header("Access-Control-Allow-Headers").get.toString shouldBe "Access-Control-Allow-Headers: x-custom-header"
} else {
header("Access-Control-Allow-Headers").get.toString shouldBe "Access-Control-Allow-Headers: Authorization, Origin, X-Requested-With, Content-Type, Accept, User-Agent"
}
}
}
}
}
}
it should s"invoke action with head verb (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
invocationsAllowed += 1
actionResult = Some(JsObject("headers" -> JsObject("location" -> "http://openwhisk.org".toJson)))
Head(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
header("location").get.toString shouldBe "location: http://openwhisk.org"
}
}
}
it should s"handle html web action with text/xml response (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.html").foreach { path =>
val html = """<html><body>test</body></html>"""
val xml = """<?xml version="1.0" encoding="UTF-8"?><note><from>test</from></note>"""
invocationsAllowed += 2
actionResult = Some(JsObject("html" -> xml.toJson))
Seq((html, MediaTypes.`text/html`), (xml, MediaTypes.`text/html`)).foreach {
case (res, expectedMediaType) =>
actionResult = Some(JsObject("html" -> res.toJson))
Get(s"$testRoutePath/$path") ~> addHeader("Accept", expectedMediaType.value) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
contentType shouldBe ContentTypes.`text/html(UTF-8)`
responseAs[String] shouldBe res
mediaType shouldBe expectedMediaType
}
}
}
}
it should s"not fail a raw http action when query or body parameters overlap with final action parameters (auth? ${creds.isDefined})" in {
implicit val tid = transid()
invocationsAllowed = 2
val queryString = "x=overridden&key2=value2"
Post(s"$testRoutePath/$systemId/proxy/raw_export_c.json?$queryString") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "raw_export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.body -> "".toJson, webApiDirectives.query -> queryString.toJson).toJson.asJsObject,
creds,
pkgName = "proxy"))
}
Post(
s"$testRoutePath/$systemId/proxy/raw_export_c.json",
JsObject("x" -> "overridden".toJson, "key2" -> "value2".toJson)) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "raw_export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.query -> "".toJson, webApiDirectives.body -> Base64.getEncoder.encodeToString {
JsObject("x" -> JsString("overridden"), "key2" -> JsString("value2")).compactPrint.getBytes
}.toJson).toJson.asJsObject,
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
it should s"invoke raw action ensuring body and query arguments are set properly (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val queryString = "key1=value1&key2=value2"
Seq(
"1,2,3",
JsObject("a" -> "A".toJson, "b" -> "B".toJson).prettyPrint,
JsObject("a" -> "A".toJson, "b" -> "B".toJson).compactPrint).foreach { str =>
Post(
s"$testRoutePath/$systemId/proxy/raw_export_c.json?$queryString",
HttpEntity(ContentTypes.`application/json`, str)) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
invocationsAllowed += 1
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "raw_export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.body -> Base64.getEncoder.encodeToString {
str.getBytes
}.toJson, webApiDirectives.query -> queryString.toJson).toJson.asJsObject,
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
}
it should s"invoke raw action ensuring body and query arguments are empty strings when not specified in request (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Post(s"$testRoutePath/$systemId/proxy/raw_export_c.json") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
invocationsAllowed += 1
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "raw_export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.body -> "".toJson, webApiDirectives.query -> "".toJson).toJson.asJsObject,
creds,
pkgName = "proxy"))
}
}
it should s"reject invocation of web action with invalid accept header (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
actionResult = Some(JsObject("body" -> "Plain text".toJson))
invocationsAllowed += 1
Get(s"$testRoutePath/$path") ~> addHeader("Accept", "application/json") ~> Route.seal(routes(creds)) ~> check {
status should be(NotAcceptable)
response shouldBe HttpResponse(
NotAcceptable,
entity = "Resource representation is only available with these types:\ntext/html; charset=UTF-8")
}
}
}
it should s"reject invocation of web action which has no entitlement (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.http").foreach { path =>
actionResult = Some(JsObject("body" -> "Plain text".toJson))
failCheckEntitlement = true
Get(s"$testRoutePath/$path") ~> Route.seal(routes(creds)) ~> check {
status should be(Forbidden)
}
}
}
it should s"not invoke an action more than once when determining entity type (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq(s"$systemId/proxy/export_c.html").foreach { path =>
val html = """<html><body>test</body></html>"""
val xml = """<?xml version="1.0" encoding="UTF-8"?><note><from>test</from></note>"""
invocationsAllowed += 1
actionResult = Some(JsObject("html" -> xml.toJson))
Get(s"$testRoutePath/$path") ~> addHeader("Accept", MediaTypes.`text/xml`.value) ~> Route.seal(routes(creds)) ~> check {
status should be(NotAcceptable)
}
}
withClue(s"allowed invoke count did not match actual") {
invocationsAllowed shouldBe invocationCount
}
}
it should s"invoke web action ensuring JSON value body arguments are received as is (auth? ${creds.isDefined})" in {
implicit val tid = transid()
Seq("this is a string".toJson, JsArray(1.toJson, "str str".toJson, false.toJson), true.toJson, 99.toJson)
.foreach { str =>
invocationsAllowed += 1
Post(
s"$testRoutePath/$systemId/proxy/export_c.json",
HttpEntity(ContentTypes.`application/json`, str.compactPrint)) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.body -> str).toJson.asJsObject,
creds,
pkgName = "proxy",
headers = List(`Content-Type`(ContentTypes.`application/json`))))
}
}
}
it should s"invoke web action ensuring binary body is base64 encoded (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val entity = HttpEntity(ContentType(MediaTypes.`image/png`), Base64.getDecoder().decode(pngSample))
invocationsAllowed += 1
Post(s"$testRoutePath/$systemId/proxy/export_c.json", entity) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
val response = responseAs[JsObject]
response shouldBe JsObject(
"pkg" -> s"$systemId/proxy".toJson,
"action" -> "export_c".toJson,
"content" -> metaPayload(
Post.method.name.toLowerCase,
Map(webApiDirectives.body -> pngSample.toJson).toJson.asJsObject,
creds,
pkgName = "proxy",
headers = List(RawHeader(`Content-Type`.lowercaseName, MediaTypes.`image/png`.toString))))
}
}
it should s"allowed string based status code (auth? ${creds.isDefined})" in {
implicit val tid = transid()
invocationsAllowed += 2
actionResult = Some(JsObject(webApiDirectives.statusCode -> JsString("200")))
Head(s"$testRoutePath/$systemId/proxy/export_c.http") ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
}
actionResult = Some(JsObject(webApiDirectives.statusCode -> JsString("xyz")))
Head(s"$testRoutePath/$systemId/proxy/export_c.http") ~> Route.seal(routes(creds)) ~> check {
status should be(BadRequest)
}
}
it should s"support json (including +json subtypes) (auth? ${creds.isDefined})" in {
implicit val tid = transid()
val path = s"$systemId/proxy/export_c.json"
val entity = JsObject("field1" -> "value1".toJson)
Seq(
ContentType(MediaType.applicationWithFixedCharset("cloudevents+json", HttpCharsets.`UTF-8`)),
ContentTypes.`application/json`).foreach { ct =>
invocationsAllowed += 1
Post(s"$testRoutePath/$path", HttpEntity(ct, entity.compactPrint)) ~> Route.seal(routes(creds)) ~> check {
status should be(OK)
responseAs[JsObject].fields("content").asJsObject.fields("field1") shouldBe entity.fields("field1")
}
}
}
}
class TestingEntitlementProvider(config: WhiskConfig, loadBalancer: LoadBalancer)
extends EntitlementProvider(config, loadBalancer, ControllerInstanceId("0")) {
// The check method checks both throttle and entitlement.
protected[core] override def check(user: Identity, right: Privilege, resource: Resource)(
implicit transid: TransactionId): Future[Unit] = {
val subject = user.subject
// first, check entitlement
if (failCheckEntitlement) {
Future.failed(RejectRequest(Forbidden))
} else {
// then, check throttle
logging.debug(this, s"test throttle is checking user '$subject' has not exceeded activation quota")
failThrottleForSubject match {
case Some(subject) if subject == user.subject =>
Future.failed(RejectRequest(TooManyRequests, Messages.tooManyRequests(2, 1)))
case _ => Future.successful({})
}
}
}
protected[core] override def grant(user: Identity, right: Privilege, resource: Resource)(
implicit transid: TransactionId) = ???
/** Revokes subject right to resource by removing them from the entitlement matrix. */
protected[core] override def revoke(user: Identity, right: Privilege, resource: Resource)(
implicit transid: TransactionId) = ???
/** Checks if subject has explicit grant for a resource. */
protected override def entitled(user: Identity, right: Privilege, resource: Resource)(
implicit transid: TransactionId) = ???
}
}