Publié par

Il y a 6 années -

Temps de lecture 6 minutes

Tester des controllers securisés dans Play 2.0

Il est fréquent que les applications web aient besoin de sécuriser certaines actions en s’assurant qu’un utilisateur identifié est connecté. L’API de Play propose l’API Security pour faciliter et uniformiser la mise en place de la sécurité sur les actions avec une approche basée sur l’extraction d’un userid depuis le cookie de session. Malheureusement l’utilisation de cette API est assez peu documentée et les actions qui l’utilisent sont difficile à tester, que ce soit en direct ou à travers le router. Ce problème remonté aux développeurs du framework a été corrigé dans une branche non publiée de la version 2.0 et intégré en 2.1. Avec l’approche de la publication de la version 2.1, il est de moins en moins probable que le correctif 2.0 soit publié un jour. En attendant de faire une migration à Play 2.1, ce court article vous montre comment backporter le helper de test de ces actions dans votre projet. 

Le problème

Pour réaliser des tests d’intégration sans démarrer un serveur, l’api de Play propose d’utiliser un objet Helpers contenant une fonction utilitaire qui se charge d’exercer le routeur de requêtes et d’exécuter une action donnée : play.api.test.Helpers.routeAndCall. La signature de cette fonction est la suivante :

    def routeAndCall[T](request: FakeRequest[T]): Option[Result]

En interne cette fonction appelle en fait une seconde forme de la méthode routeAndCall qui a la définition suivante:

  def routeAndCall[T, ROUTER <: play.core.Router.Routes](router: Class[ROUTER], request: FakeRequest[T]): Option[Result] = {
    val routes = router.getClassLoader.loadClass(router.getName + "$").getDeclaredField("MODULE$").get(null).asInstanceOf[play.core.Router.Routes]
    routes.routes.lift(request).map {
      case action: Action[_] =>; action.asInstanceOf[Action[T]](request)
    }
  }

Dans cette version, le helper s’attend à recevoir une action répondant à une signature équivalente à :

 def action(request:Request):Option[Result]

Cependant les actions qui utilisent security sont wrappées par une fonction qui applique l’authentification, la signature ne correspond pas et tenter de définir un test qui appelle routeAndCall sur une action sécurisée donne l’erreur suivante :

error]     ClassCastException: play.api.mvc.AnyContentAsEmpty$ cannot be cast to scala.Tuple2 (Security.scala:53)
[error] play.api.mvc.Security$$anonfun$Authenticated$1.apply(Security.scala:54)
[error] play.api.mvc.Security$$anonfun$Authenticated$1.apply(Security.scala:53)
[error] play.api.mvc.Action$$anon$1.apply(Action.scala:170)
[error] play.api.test.Helpers$$anonfun$routeAndCall$1.apply(Helpers.scala:179)
[error] play.api.test.Helpers$$anonfun$routeAndCall$1.apply(Helpers.scala:178)
[error] play.api.test.Helpers$.routeAndCall(Helpers.scala:178)
[error] play.api.test.Helpers$.routeAndCall(Helpers.scala:170)
[error] controllers.ApplicationSpec$$anonfun$1$$anonfun$apply$3$$anonfun$apply$4.apply(ApplicationSpec.scala:14)
[error] controllers.ApplicationSpec$$anonfun$1$$anonfun$apply$3$$anonfun$apply$4.apply(ApplicationSpec.scala:13)
[error] play.api.test.Helpers$.running(Helpers.scala:33)
[error] controllers.ApplicationSpec$$anonfun$1$$anonfun$apply$3.apply(ApplicationSpec.scala:13)
[error] controllers.ApplicationSpec$$anonfun$1$$anonfun$apply$3.apply(ApplicationSpec.scala:13)

La solution

Il faut redéfinir la fonction routeAndCall et l’appeller à la place de celle présente dans le framework. J’ai repris tel quel le code du correctif dans le repository git et je l’ai placé dans mon propre objet Helpers dans un package helpers.

package helpers


import play.api.mvc._
import play.api.http._
import play.api.libs.concurrent._

import play.api.test.FakeRequest


object Helpers extends Status with HeaderNames {

  /**
   * Use the Router to determine the Action to call for this request and executes it.
   */
  def myRouteAndCall[T](request: FakeRequest[T]): Option[Result] = {
    routeAndCall(this.getClass.getClassLoader.loadClass("Routes").asInstanceOf[Class[play.core.Router.Routes]], request)
  }
  /**
   * Use the Router to determine the Action to call for this request and executes it.
   */
  def myRouteAndCall[T, ROUTER<: play.core.Router.Routes](router: Class[ROUTER], request: FakeRequest[T]): Option[Result] = {
    val routes = router.getClassLoader.loadClass(router.getName + "$").getDeclaredField("MODULE$").get(null).asInstanceOf[play.core.Router.Routes]
    routes.routes.lift(request).map {
      case a: Action[_] =>
        val action = a.asInstanceOf[Action[T]]
        val parsedBody: Option[Either[play.api.mvc.Result,T]] = action.parser(request).fold(
          (a,in) => Promise.pure(Some(a)),
          k => Promise.pure(None),
          (msg,in) => Promise.pure(None)).await.get
        parsedBody.map{resultOrT =>
          resultOrT.right.toOption.map{innerBody =>
            action(FakeRequest(request.method, request.uri, request.headers, innerBody))
          }.getOrElse(resultOrT.left.get)
        }.getOrElse(action(request))

    }
  }
}

Le package ou le nom de l’objet n’ont pas d’importance, vous pouvez le changer librement. Vous pouvez également changer le nom de la méthode myRouteAndCall si vous voulez.
Etant donné le controller suivant (le trait secured est directement copié des projets d’exemple) :

package controllers

import play.api._
import play.api.mvc._


trait App{
  this: Controller with Secured=>

  def index = IsAuthenticated { user=>implicit request =>
    Ok("")
  }
}

/**
 * Provide security features
 */
trait Secured {

  /**
   * Retrieve the connected user email.
   */
  private def username(request: RequestHeader) = request.session.get("email")

  /**
   * Redirect to login if the user in not authorized.
   */
  private def onUnauthorized(request: RequestHeader) = {
    Logger.info("Unauthorized access to "+request.uri+" , redirecting to"+routes.Application.login())
    Results.Redirect(routes.Application.login()).withSession("before_auth_requested_url"-> request.uri)
  }

  // ---

  /**
   * Action for authenticated users.
   */
  def IsAuthenticated(f: => String => Request[AnyContent] => Result): Action[(Action[AnyContent], AnyContent)] = Security.Authenticated(username, onUnauthorized) { userId =>
    Action({  request =>
      Logger.info("Authorized access to "+request.uri+" , for user "+userId)
      f(userId)(request)
    })
  }

  /**
   * Action for authenticated users.
   */
  def IsAuthenticated[A](parser: BodyParser[A])(f: => String => Request[A] => Result) = Security.Authenticated(username, onUnauthorized) { user =>
    Action(parser)(request => f(user)(request))
  }
}

object App extends Controller with Secured with App 

Le test  correspondant est

package controllers
import org.specs2.mutable._

import play.api.test._
import helpers.Helpers._
import play.api.test.Helpers._
pers._

class ApplicationSpec extends Specification {

 "App" should {
    "access home when logged in" in {
      running(FakeApplication(additionalConfiguration = inMemoryDatabase())) {
        val result = myRouteAndCall(FakeRequest(GET,"/").withSession("email" -> "email@sample.com")).get
        status(result) must equalTo(200)
      }
    }

  }
}

Cerise sur le gâteau

Si vous voulez nommer votre propre helper routeAndCall vous aurez une collision avec la méthode du framework. Il est cependant possible d’utiliser une astuce de scala pour éviter la collision:

Le test de l’action index ressemble donc à

class ApplicationSpec extends Specification {

  "App" should {
    "access home when logged in" in {
      running(FakeApplication(additionalConfiguration = inMemoryDatabase())) {
        val result = routeAndCall(FakeRequest(GET, "/").withSession("email" -> "email@sample.com")).get
        status(result) must equalTo(200)
      }
    }
  }
}

Reste à appliquer les bons imports. Dans les projets examples, les tests utilisant routeAndCall importent play.api.test.Helpers._ Si vous faites de même et tentez d’importer helpers.Helpers._ en plus, vous aurez une erreur de compilation. Il faut importer play.api.test.Helpers._ en excluant routeAndCall. c’est possible avec la forme suivante: 

import helpers.Helpers._
import play.api.test.Helpers.{routeAndCall=>_,_}

Le code complet du test devient donc

package controllers
import org.specs2.mutable._

import play.api.test._
import helpers.Helpers._
import play.api.test.Helpers.{routeAndCall=>_,_}
import play.api.test.Helpers._

class ApplicationSpec extends Specification {

  "Application" should {
   "access home when logged in"in {
      running(FakeApplication(additionalConfiguration = inMemoryDatabase())) {
        val result = routeAndCall(FakeRequest(GET, "/").withSession("email" -> "email@sample.com")).get
        status(result) must equalTo(200)
      }
    }

  }
}

Publié par

Publié par Jean Helou

Jean Helou est un passionné aux opinions tranchées. Il essaye régulièrement de nouvelles approches, outils, patterns et révise ses opinions en fonction. Très curieux il a expérimenté avec un grand nombre de technologies et de modèles applicatifs. Jean est convaincu que les machines sont au service de l'homme, pas l'inverse. Il essaye toujours de faire en sorte que les logiciels qu'il développe reflètent cette conviction.

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.