Archives mensuelles : avril 2020

Double authentification avec Symfony

Qu’est-ce que la double authentification ?

Tout le monde connaît l’authentification avec un identifiant et un mot de passe. Cependant, ce système est faillible pour plusieurs raisons :

  • Ils sont souvent les mêmes pour plusieurs sites.
  • Ils sont souvent mal stockés
  • Ils sont souvent trop faciles à trouver
  • Pour les mots de passe forts, les retenir tous est compliqué.

Ainsi, pour augmenter la sécurité lors de l’authentification, il est de plus en plus souvent demandé une seconde preuve que vous êtes bien la personne que vous prétendez être.

C’est la double authentification.

Quand la mettre en place ?

En trois mots : tout le temps. Il y a toujours un usage pirate possible pour toutes les pages protégées par une authentification.
Certains trouveront la réponse extrême. Cependant, pourquoi protéger par un mot de passe quelque chose qui n’a aucune valeur ?

Quels sont les différents types de double authentification ?

Beaucoup connaissent la double authentification avec l’envoi d’un message texte sur votre téléphone portable. Il en existe d’autres :

  • L’utilisation d’une clé physique (USB ou sans fil).
  • L’utilisation d’un code temporaire basé sur le temps.
  • L’utilisation d’un code temporaire envoyé à l’utilisateur selon un moyen que l’utilisateur a choisi (message texte, courriel).
  • L’utilisation d’une authentification via une application mobile liée au service en ligne.

Comment implémenter une double authentification en deux étapes avec Symfony ?

Avant de commencer, nous allons mettre en place une double authentification par l’envoi d’un code temporaire par courriel.
Voici le schéma du dialogue entre le navigateur et les serveurs.

Dialogue Double Authentification

La première authentification via un formulaire est déjà implémentée par Symfony. Cependant, il est nécessaire de personnaliser la vérification du mot de passe via le module Guard.

Dans notre Guard, nous allons stopper l’authentification si le mot de passe est correct et rediriger l’utilisateur vers la page de saisie du second facteur.

Avant la redirection, il est nécessaire de sauvegarder le nom de l’utilisateur en cours de connexion et envoyer un code temporaire sur la boîte courriel de l’utilisateur.

Une fois saisi par l’utilisateur, un autre Guard se chargera de vérifier le code saisi et valider la connexion effective de l’utilisateur.

Simple, non ?

Faisons les courses

Pour mettre en place correctement la double authentification, nous avons besoin de :

  • Sauvegarder le nom de l’utilisateur en cours de connexion.
  • Générer un code et l’associer à l’utilisateur en cours de connexion.
  • Définir un délai de réponse et sauvegarder la valeur limite.
  • Vérifier que l’utilisateur ne teste pas trop de codes (brute force).

Si j’oublie une mesure de sécurité, dites-le moi dans les commentaires.

Ajoutons notre second Guard

La classe AppTwoFactorAuthenticator

<?php declare(strict_types=1);

namespace AppSecurity;

use AppEntityUser;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentHttpFoundationRedirectResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationSessionSessionInterface;
use SymfonyComponentRoutingGeneratorUrlGeneratorInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreExceptionCustomUserMessageAuthenticationException;
use SymfonyComponentSecurityCoreExceptionInvalidCsrfTokenException;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;
use SymfonyComponentSecurityCsrfCsrfToken;
use SymfonyComponentSecurityCsrfCsrfTokenManagerInterface;
use SymfonyComponentSecurityGuardAuthenticatorAbstractFormLoginAuthenticator;
use SymfonyComponentSecurityHttpUtilTargetPathTrait;

final class AppTwoFactorAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_two_factor';

    public const USER_SESSION_KEY = 'two_auth_user';
    public const CODE_SESSION_KEY = 'two_auth_code';
    public const TIMEOUT_SESSION_KEY = 'two_auth_timeout';
    public const COUNT_SESSION_KEY = 'two_auth_count';

    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    /**
     * @var SessionInterface
     */
    private $session;

    public function __construct(
        EntityManagerInterface $entityManager,
        UrlGeneratorInterface $urlGenerator,
        CsrfTokenManagerInterface $csrfTokenManager,
        SessionInterface $session
    ) {
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->session = $session;
    }

    public function supports(Request $request)
    {
        return self::LOGIN_ROUTE === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        return [
            'email' => $request->getSession()->get(self::USER_SESSION_KEY),
            'count' => $request->getSession()->get(self::COUNT_SESSION_KEY, 1),
            'timeout' => $request->getSession()->get(self::TIMEOUT_SESSION_KEY),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        if (time() > $credentials['timeout']) {
            throw new TwoFactorTimedoutException();
        }

        if ($credentials['count'] >= 3) {
            throw new TwoFactorMaxAttemptReachedException();
        }

        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($credentials['password'] === $this->session->get(self::CODE_SESSION_KEY, null)) {
            return true;
        }

        $this->session->set(self::COUNT_SESSION_KEY, $credentials['count'] + 1);

        return false;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $request->getSession()->remove('need_auth_two');
        $request->getSession()->remove(self::USER_SESSION_KEY);
        $request->getSession()->remove(self::CODE_SESSION_KEY);
        $request->getSession()->remove(self::TIMEOUT_SESSION_KEY);
        $request->getSession()->remove(self::COUNT_SESSION_KEY);

        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse('/');
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

La constante LOGIN_ROUTE contient le nom de la route dédiée à ce Guard. Le contrôleur doit être défini, nous le verrons plus bas.

Cette classe définit 4 constantes qui permettront de stocker les informations utiles dans la session. Vous pouvez tout à fait sauvegarder ces informations en base de données.

Activer le second Guard

Dans le fichier config/packages/security.yaml, ajouter le nom complet de la classe dans la liste des authenticators :

#[...]
    firewalls:
        main:
            guard:
                authenticators:
                    - AppSecurityAppLoginAuthenticator
                    - AppSecurityAppTwoFactorAuthenticator
                entry_point: AppSecurityAppLoginAuthenticator

Maintenant, Symfony a besoin de connaître le Guard principal. Ajouter la clé entry_point avec comme valeur le nom complet de la classe du Guard gérant la connexion par identifiant et mot de passe.

Ajouter le contrôleur

Maintenant, ajouter le contrôleur lié au Guard du second facteur. Ce contrôleur se comporte comme celui du Guard de l’authentification par identifiant et mot de passe avec une différence. Il n’est pas possible de modifier l’utilisateur.

Voici un exemple de la méthode contrôleur :

//Fichier : src/Controller/SecurityController.php

    /**
     * @Route("/two_factor", name="app_two_factor")
     */
    public function twoFactor(SessionInterface $session, CodeGeneratorInterface $codeGenerator, AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        if ($session->get(AppTwoFactorAuthenticator::CODE_SESSION_KEY) === null) {
            $error = null;
            $session->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, $codeGenerator->generate());
            $session->set(AppTwoFactorAuthenticator::TIMEOUT_SESSION_KEY, time() + (60 * 5));
            $session->set(AppTwoFactorAuthenticator::COUNT_SESSION_KEY, 1);
            //Send here the code by email.
        }
        return $this->render('security/two_factor.html.twig', ['error' => $error]);
    }

Ici, nous retrouvons le nom de la route définie dans le Guard pour la double authentification.

Le rôle du contrôleur est d’afficher les erreurs de saisie du code et si le code n’existe pas, il l’initialise et l’envoie par courriel.

Le fichier Twig lié ressemble à ceci :

{% extends 'base.html.twig' %}

{% block title %}
Two factor auth
{% endblock %}

{% block body %}
    <form method="post">
        {% if error %}
            <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
        {% endif %}

        <h1 class="h3 mb-3 font-weight-normal">Please confirm your identity</h1>
        <label for="inputPassword">Password</label>
        <input type="password" name="password" id="inputPassword" class="form-control" required>

        <input type="hidden" name="_csrf_token"
               value="{{ csrf_token('authenticate') }}"
        >
        <button class="btn btn-lg btn-primary" type="submit">
            Send code
        </button>
    </form>
{% endblock %}

Activer la double authentification

La seconde authentification est presque prête. Cependant, il n’est pas possible de l’utiliser. Pour l’utiliser, il est nécessaire de modifier la classe Guard liée à l’authentification par utilisateur et mot de passe.

La première modification consiste à indiquer que l’authentification a échoué dans tous les cas. Nous allons cependant indiquer qu’il faut rediriger vers le second facteur d’authentification grâce à une information stockée en session.

Voici le code de la méthode liée à la vérification du mot de passe :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($this->encoder->getEncoder($user)->isPasswordValid($user->getPassword(), $credentials['password'], null)) {
            $this->session->set('need_auth_two', true);
            return false;
        }
        return false;
    }

Maintenant que l’authentification échoue systématiquement, comment renvoyer l’utilisateur ayant saisi son mot de passe correct vers la double authentification ?

Cela se passe dans la fonction getLoginUrl du Guard

// Fichier : src/Security/AppLoginAuthenticator.php
    protected function getLoginUrl()
    {
        if ($this->session->get('need_auth_two', false) === true) {
            return $this->urlGenerator->generate(AppTwoFactorAuthenticator::LOGIN_ROUTE);
        }
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }

Dans le cas où la session contient la clé need_auth_two et que sa valeur est à vrai, alors l’URL retournée est celle correspondant à la page de l’authentification à deux facteurs.

Éviter la réutilisation du code ou la mauvaise redirection

Avec le code tel qu’il est actuellement, il est possible d’être redirigé vers la page de la double authentification alors que le mot de passe est erroné.

Il est également possible de réutiliser un code déjà utilisé si la session n’est pas complètement renouvelée lors de la déconnexion.

Pour éviter ces mauvais effets, nous allons réinitialiser le code et la demande de double authentification lors de la récupération de l’identifiant et du mot de passe saisis par l’utilisateur.

Voici donc le contenu de la méthode getCredentials du Guard AppLoginAuthenticator :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );
        $request->getSession()->set(
            AppTwoFactorAuthenticator::USER_SESSION_KEY,
            $credentials['email']
        );

        $request->getSession()->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, null);
        $request->getSession()->set('need_auth_two', false);

        return $credentials;
    }

C’est également ici que nous sauvegardons le nom d’utilisateur saisi pour être utilisé lors de la double authentification.

Conclusion

La double authentification est maintenant prête à minima. Il reste encore quelques améliorations telles que :

  • Utiliser un événement pour déclencher la génération du code et son envoi.
  • Permettre le changement d’utilisateur lors de la double authentification.

Mais cela fera peut-être l’objet d’un autre billet de blog.

Dites-moi dans les commentaires comment vous avez implémenté la double authentification sur vos projets. Je suis à votre service si vous avez des questions.

PS J’ai utilisé ce projet pour écrire ce billet de blog.

[Tutoriel] Test Behat sur un projet Symfony

But de ce tutoriel

Après vous avoir présenté le design pattern PageObject dans un précédent billet de blog, nous allons voir ensemble comment le mettre en pratique sur un projet Web.

Ici nous vérifions que toutes les briques de l’application fonctionnent ensemble correctement. Nous allons utiliser le driver Symfony de Mink pour permettre la simulation des requêtes HTTP et la récupération des réponses.

J’ai utilisé ce projet pour écrire ce billet de blog.

Installation

Nous allons avoir besoin de plusieurs dépendances uniquement pour le développement.

La première est Behat lui-même. Ensuite Doctrine data fixtures permettra de réinitialiser la base de données entre chaque scenarii.
Puis viennent les extensions pour Behat telles que MinkExtension et SymfonyExtension. Mink BrowserKit Driver est requise par MinkExtension sans être requise dans son composer.json.

$ composer require --dev behat/behat doctrine/data-fixtures friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension friends-of-behat/page-object-extension friends-of-behat/symfony-extension

Si vous êtes sur la branche master du projet, Flex vous demandera la confirmation de l’application de la recette du packet friends-of-behat/symfony-extension. Il est préférable d’accepter.

Config

Le fichier behat.yml.dist a été ajouté à la racine du projet.

Modifier son contenu pour qu’il ressemble à celui-ci :

default:
    suites:
        default:
            contexts:
                - AppBehatDemoContext

    extensions:
        BehatMinkExtension:
            base_url: "https://localhost/"
            default_session: symfony
            sessions:
                symfony:
                    symfony: ~
        FriendsOfBehatSymfonyExtension: null

Ici nous activons et configurons les extensions installées précédemment.

Pour permettre la suite des opérations, nous avons besoin de modifier le fichier config/services_test.yaml.

Ce fichier contient la configuration des services spécifiques à l’environnement de test.

Nous y définissons des alias, dont celui du générateur de code de double authentification pour le permettre d’être prévisible.

services:
    _defaults:
        autowire: true
        autoconfigure: true

    DoctrinePersistenceObjectManager: '@doctrine.orm.default_entity_manager'

    AppSecurityCodeGeneratorInterface: '@AppTestsServiceDefinedCodeGenerator'

    AppTestsService:
        resource: '../tests/Service/*'

    AppTestsFunctional:
        resource: '../tests/Functional/*'
        exclude: '../tests/Functional/{Page}'

    AppTestsFunctionalPage:
        resource: '../tests/Functional/Page/*'
        parent: 'FriendsOfBehatPageObjectExtensionPageSymfonyPage'
        autoconfigure: false
        public: false
        autowire: true

    FriendsOfBehatPageObjectExtensionPagePage:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        arguments:
            - '@behat.mink.default_session'
            - '@behat.mink.parameters'

    FriendsOfBehatPageObjectExtensionPageSymfonyPage:
        abstract: true
        autoconfigure: false
        public: false
        autowire: true
        parent: 'FriendsOfBehatPageObjectExtensionPagePage'
        arguments:
            - '@router'

Comme vous pouvez le voir, deux services abstraits sont définis pour les PageObject. Le service SymfonyPage sera utilisé par la configuration de tous les services présents dans le dossier tests/Functional/Page.

Notre projet est configuré.

Initialiser l’arborescence

Comme expliqué dans mon premier blog post’ sur le sujet, nous allons ajouter les dossiers qui contiendront les contextes et les PageObject.

tests
├── bootstrap.php
├── Functional
│   ├── Context
│   │   ├── Task
│   │   └── TodoList
│   ├── Hook
│   ├── Page
│   │   ├── Task
│   │   └── TodoList
│   └── Setup
└── Service

Dans le cas de mon projet, j’ai besoin de modifier le comportement du générateur de code de double authentification pour le rendre prévisible. C’est pour cela que le dossier Service a été ajouté.

Implémenter le service de génération de code

Dans le cas des tests, l’utilisation du hasard n’est pas possible. C’est pourquoi nous allons réimplémenter une interface pour définir un service spécifique au fonctionnement des tests.

Ajouter le fichier tests/Service/DefinedCodeGenerator.php avec son contenu :

<?php

declare(strict_types=1);

namespace AppTestsService;

use AppSecurityCodeGeneratorInterface;

class DefinedCodeGenerator implements CodeGeneratorInterface
{

    public function generate(): string
    {
        return '1234';
    }
}

Ajouter les scenarii de test

Comme les tests utilisent le langage naturel, il est préférable de commencer par l’écriture des tests.

Ajouter le fichier features/Login.feature avec le contenu suivant :

#language: fr
Fonctionnalité: Connexion à l'application

  Contexte:
    Etant donné que l'utilisateur "todo@me.fr" est enregistré avec le mot de passe "MonSuperPassWord"

  Scénario: Connexion
    Etant donné que je suis sur la page de connexion
    Lorsque je me connecte en tant que "todo@me.fr" avec le mot de passe "MonSuperPassWord"
    Alors je dois être sur la page de double authentification
    Lorsque je saisis le code de double authentification
    Alors je dois être sur la liste des todo listes

Supprimer le fichier demo.feature.

Aucune des phrases utilisées dans le scénario n’est connue de Behat.

Contexte d’initialisation

La section Contexte: de la fonctionnalité permet la réalisation d’action avant chaque scénario. Cela permet d’éviter les répétitions en début de scénario.

La seule phrase présente permet d’ajouter un utilisateur et son mot de passe dans la base de données.

Comme il s’agit de mise en place des données utiles aux tests, nous sommes donc dans un contexte Setup et, comme cela concerne un utilisateur, nous allons donc ajouter le UserContext.

Ajouter le fichier tests/Functional/Setup/UserContext.php avec son contenu :

<?php declare(strict_types=1);

namespace AppTestsFunctionalSetup;

use AppEntityUser;
use BehatBehatContextContext;
use DoctrinePersistenceObjectManager;
use SymfonyComponentSecurityCoreEncoderEncoderFactoryInterface;

class UserContext implements Context
{
    /**
     * @var ObjectManager
     */
    private $objectManager;
    /**
     * @var EncoderFactoryInterface
     */
    private $encoderFactory;

    public function __construct(ObjectManager $objectManager, EncoderFactoryInterface $encoderFactory)
    {
        $this->objectManager = $objectManager;
        $this->encoderFactory = $encoderFactory;
    }

    /**
     * @Given /^l'utilisateur "([^"]+)" est enregsitré avec le mot de passe "([^"]+)"$/
     */
    public function registerUser($email, $password)
    {
        $user = new User();
        $user->setEmail($email);
        $user->setPassword($this->encoderFactory->getEncoder($user)->encodePassword($password, null));
        $this->objectManager->persist($user);
        $this->objectManager->flush();
    }

}

La phrase est maintenant définie grâce à une expression rationnelle ayant des parenthèses capturant là où nous souhaitons récupérer des informations.

Pour permettre l’utilisation de cette phrase dans les scénarii de test, il est nécessaire d’ajouter le namespace de cette classe dans la configuration de Behat :

default:
    suites:
        default:
            contexts:
                - AppTestsFunctionalSetupUserContext

Maintenant, l’utilisateur sera ajouté avant l’exécution du scénario.

Contexte d’action

Pour les autres actions, nous allons définir un contexte par page. Cela permet de garder les choses rangées.

Les contextes seront enrichis au fur et à mesure que les tests seront écrits.

Action sur la page de connexion

Dans ce contexte, deux phrases seront définies : celle ouvrant la page de connexion et celle réalisant la connexion d’un utilisateur.

Voici le contenu de ce contexte :

// Fichier : tests/Functional/Context/LoginContext.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalContext;

use AppTestsFunctionalPageLoginPage;
use BehatBehatContextContext;

class LoginContext implements Context
{
    /**
     * @var LoginPage
     */
    private $loginPage;

    public function __construct(LoginPage $loginPage)
    {
        $this->loginPage = $loginPage;
    }

    /**
     * @Given je suis sur la page de connexion
     */
    public function jeSuisSurLaPageDeConnexion()
    {
        $this->loginPage->open();
    }

    /**
     * @When /^je me connecte en tant que "([^"]+)" avec le mot de passe "([^"]+)"$/
     */
    public function connexion($email, $password)
    {
        $this->loginPage->login($email, $password);
    }
}

Vous voyez ainsi que le contexte ne contient pas beaucoup de code. Il ressemble à un adaptateur.

Voici le service PageObjet qui est implémenté dans la classe LoginPage :

// Fichier : tests/Functional/Page/LoginPage.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalPage;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

class LoginPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'app_login';
    }

    public function login($user, $password)
    {
        $this->open();
        $this->getDocument()->fillField('email', $user);
        $this->getDocument()->fillField('password', $password);
        $this->getDocument()->pressButton('Sign in');
    }
}

Ici également, il n’y a pas beaucoup de code. La fonction open est gérée par la classe SymfonyPage.

La méthode login ouvre la page de login, renseigne le formulaire de connexion puis le valide.

Pour activer ce contexte, ajoutons-le à la configuration de Behat.

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - AppTestsFunctionalContextLoginContext

Action sur la page du second facteur

Maintenant que nous avons réalisé la prise en charge de la page de connexion, il est maintenant nécessaire de gérer la page de la double authentification.

Comme il s’agit d’une nouvelle page, nous allons donc ajouter un nouveau contexte et un nouveau service PageObjet étendant SymfonyPage.

// Fichier : tests/Functional/Context/TwoFactorContext.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalContext;

use AppTestsFunctionalPageTwoFactorPage;
use BehatBehatContextContext;

class TwoFactorContext implements Context
{
    /**
     * @var TwoFactorPage
     */
    private $twoFactorPage;

    public function __construct(TwoFactorPage $twoFactorPage)
    {
        $this->twoFactorPage = $twoFactorPage;
    }

    /**
     * @When je saisis le code de double authentification
     */
    public function saisieDoubleAuth()
    {
        $this->twoFactorPage->sendCode('1234');
    }

    /**
     * @Then je dois être sur la page de double authentification
     */
    public function verifPageTwoFactor()
    {
        $this->twoFactorPage->verify();
    }
}
// Fichier : tests/Functional/Page/TwoFactorPage.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalPage;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

class TwoFactorPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'app_two_factor';
    }

    public function sendCode($code)
    {
        $this->verify();

        $this->getDocument()->fillField('password', $code);
        $this->getDocument()->pressButton('Send code');
    }
}

Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - AppTestsFunctionalContextLoginContext
                - AppTestsFunctionalContextTwoFactorContext

Action sur la liste des ToDo listes

Maintenant, il reste une phrase non définie dans le scénario. Elle sera définie dans le contexte lié aux listes de ToDo Liste.

Comme pour les deux contextes précédents, nous en ajouterons un troisième IndexContext avec son PageObject IndexPage. Pour ne pas les confondre avec les autres, ils sont placés dans un dossier dédié aux fonctionnalités liées aux ToDo Listes.

// Fichier : tests/Functional/Context/TodoList/IndexContext.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalContextTodoList;

use AppTestsFunctionalPageTodoListIndexPage;
use BehatBehatContextContext;

class IndexContext implements Context
{
    /**
     * @var IndexPage
     */
    private $indexPage;

    public function __construct(IndexPage $indexPage)
    {
        $this->indexPage = $indexPage;
    }

    /**
     * @Then je dois être sur la liste des todo listes
     */
    public function jeDoisEtreSurLaListeDesTodoListes()
    {
        $this->indexPage->verify();
    }
}

Même si l’objet page contient peu de code, il est là pour disposer des fonctions prédéfinies par la classe SymfonyPage.

// Fichier : tests/Functional/Page/TodoList/IndexPage.php
<?php declare(strict_types=1);

namespace AppTestsFunctionalPageTodoList;

use FriendsOfBehatPageObjectExtensionPageSymfonyPage;

class IndexPage extends SymfonyPage
{
    public function getRouteName(): string
    {
        return 'todo_list_index';
    }
}

Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :

# fichier : behat.yml.dist
default:
    suites:
        default:
            contexts:
                - AppTestsFunctionalContextLoginContext
                - AppTestsFunctionalContextTwoFactorContext
                - AppTestsFunctionalContextTodoListIndexContext

Contexte avant chaque scénario

Nous n’avons pour l’instant qu’un seul test. Le besoin de réinitialiser la base de données ne se fera sentir que lors de la prochaine exécution.

En effet, l’utilisateur existant déjà en base de données, une erreur SQL sera émise lors de la tentative d’ajout de l’utilisateur en base.

Pour éviter cela, il est possible d’exécuter du code automatiquement avant chaque scénario.

Ajouter le fichier tests/Functional/Hook/DoctrineContext.php avec son contenu :

<?php

declare(strict_types=1);

namespace AppTestsFunctionalHook;

use BehatBehatContextContext;
use DoctrineCommonDataFixturesPurgerORMPurger;
use DoctrineORMEntityManagerInterface;

class DoctrineContext implements Context
{
    /** @var EntityManagerInterface */
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    /**
     * @BeforeScenario
     */
    public function purgeDatabase()
    {
        $this->entityManager->getConnection()->getConfiguration()->setSQLLogger(null);
        $purger = new ORMPurger($this->entityManager);
        $purger->purge();
        $this->entityManager->clear();
    }
}

Pour l’activer, il est nécessaire d’ajouter ce contexte aux autres dans la configuration de Behat behat.yml.dist.

default:
    suites:
        default:
            contexts:
                - AppTestsFunctionalHookDoctrineContext

L’annotation @BeforeScenario indique à Behat qu’il faut exécuter cette fonction avant tous les scénarii.

Conclusion

Nous avons ajouté des contextes, configuré les services utiles pour les tests, configuré Behat, écrit le scénario de test et les définitions de chaque phrase. Il est maintenant possible d’exécuter les tests et de poursuivre l’écriture des scénarii.

Cette petite mise en pratique du design pattern PageObjet ne montre malheureusement pas tout son potentiel. En transposant ce procédé sur vos projets et vos besoins métier, vous pouvez entrevoir le potentiel d’une telle solution.

Je n’exclus pas la réalisation d’un ou plusieurs autres billets de blog sur le sujet pour montrer le potentiel de ce design pattern.

Dites-moi dans les commentaires si vous avez déjà utilisé ce pattern sur vos projets. Je suis à votre service si vous avez des questions.

Behat et SymfonyPage

Behat et Mink

Behat est un outil de test fonctionnel dont les scénarii sont écrits en langage commun grâce à Gerkin.
Le concept est simple, le test est divisé en 3 parties « Étant donné que » pour donner les conditions préalables, « Lorsque » pour indiquer les actions réalisées pour le test, puis « Alors » pour vérifier que les actions réalisées ont produit le résultat souhaité.

Chaque ligne du test est une phrase complète qui commence par un mot-clé. Dans cette phrase, des éléments peuvent servir de données utiles pour le test.

Chaque phrase est associée à une méthode PHP grâce aux classes de contexte. La phrase est située dans une annotation au-dessus de la fonction à appeler. L’annotation peut contenir une expression rationnelle ou une phrase avec des placeholders commençant par deux points. Chaque parenthèse de capture de l’expression rationnelle ou placeholder sera un argument de la méthode PHP appelée pour exécuter la phrase du test.

Ex :

/**
 * @Then je dois voir le texte :texte
 */
public function jeDoisVoirLeTexte($texte)
{
}

C’est ainsi que les tests peuvent être exécutés.

Afin de ne pas se répéter, les contextes sont réutilisables.

Pour tester des pages Web HTML, l’utilisation de Mink est nécessaire. Il dispose de pilote (driver) pour différentes situations.

En voici quelques-uns, chacun avec ses avantages et inconvénients :

  • Sans serveur Web et Navigateur (extension Symfony) : Simule un objet Request et récupère l’objet Response.
  • Avec un serveur Web, mais sans Navigateur (extension Goutte) : Réalise de vraies requêtes HTTP via un client HTTP sur un serveur Web et obtient une réponse HTTP. Seul le JavaScript n’est pas exécuté.
  • Avec un serveur Web, mais avec un Navigateur (extension Selenium) : Pilote un navigateur Web sans fenêtre pour réaliser les actions sur le site Web et vérifier le résultat. Ces tests sont particulièrement longs à exécuter.

Pour éviter l’écriture de fonctions complexes dans les contextes, Mink abstrait les drivers. Le cas des tests avec JavaScript est particulier, car le pilote est impatient et il génère de faux échecs de test. L’ajout de délais dans les fonctions du contexte est nécessaire.

C’est ici que les choses se compliquent. Eviter l’écriture de contexte est possible grâce au contexte proposé par Mink. Cependant, vos tests ne ressembleront pas à de vrais tests fonctionnels, car ils contiendront des classes CSS, des xPath dans vos phrases.

Un autre point est qu’une phrase ne peut être déclarée qu’une seule fois et chaque contexte est indépendant des autres. Cela bloque l’héritage des contextes par défaut de Mink pour un seul contexte. Votre contexte gonflera pour contenir tous les cas d’actions sur les pages.

Concept de PageObject

Comment diviser cet énorme contexte en contextes plus petits et surtout mieux organisés ? Comment permettre l’écriture d’un contexte par page de l’application ou par concept ?

La première approche serait d’utiliser le RawMinkContext qui apporte le strict minimum des dépendances nécessaires pour écrire des cas de test et d’actions.
Cependant, mutualiser du code utile pour plusieurs phrases ne sera pas possible. Pour cela, l’utilisation des services externes au contexte est nécessaire.

Le pattern PageObject est venu résoudre ce problème. Nous pouvons en profiter dans Behat soit par l’extension SensioLab soit par l’extension du groupe Friend of Behat.

Mais qu’est-ce donc ?

Ce sont des services qui ont une dépendance vers Mink et qui permettront de réaliser des actions sur la page actuellement chargée. Un service PageObject permet de regrouper toutes les actions possibles d’une page ou d’un élément commun du site (le concept d’Element est plus adapté dans ce cas).

Le service PageObject expose les actions disponibles et les données à récupérer sur la page pour laquelle il est dédié. Il ne fait pas d’assertion (ou très très peu).

Ce service est ensuite injecté dans le service de contexte afin de pouvoir interagir avec la page.

L’extension proposée par Friends Of Behat propose un concept de SymfonyPage qui utilise le routeur Symfony pour appeler la page (donc, générer l’URL) et vérifier que le service PageObject va réaliser une action sur la bonne page.

C’est donc dans ce service PageObject que nous allons manipuler les éléments techniques de la page Web.

Conséquence dans les Context

La première conséquence est la possibilité de séparer les définitions des phrases en plusieurs contextes indépendants.

La seconde conséquence est la possibilité d’organiser les contextes en fonction des modules de l’application et sortir ceux relatifs à l’infrastructure telle que les hook Behat et les contextes liés à la préparation de la base de données pour le test.

Ainsi, voici une architecture possible pour le dossier de tests fonctionnels :

tests
└── Functional
    ├── Context
    │   ├── Admin
    │   │   ├── LoginContext.php
    │   │   ├── NotificationContext.php
    │   │   ├── Module1
    │   │   │   └── Module1Context.php
    │   │   └── Module2
    │   │       ├── IndexModule2Context.php
    │   │       └── ShowModule2Context.php
    │   └── Module2
    │       ├── DashboardContext.php
    │       └── LoginContext.php
    ├── Hook
    │   └── FixtureContext.php
    ├── Page
    │   ├── Admin
    │   │   ├── Account
    │   │   │   └── LoginPage.php
    │   │   ├── Module1
    │   │   │   └── IndexPage.php
    │   │   └── Module2
    │   │       └── ShowPage.php
    │   └── Module2
    ├── Setup
    │   └── MonEntiteContext.php
    └── Transform
        └── QuantityContext.php

Certains auront peut-être trouvé la source d’inspiration de cette organisation. C’est celle utilisée dans les tests du projet de e-commerce Sylius.

Enfin, une conséquence non négligeable est la nécessité d’écrire plus de fichiers PHP et de code pour réaliser les tests. Mais cette conséquence disparaît vite avec la mise en pratique.

Bénéfices

Les utilisateurs réguliers des tests fonctionnels avec Behat et Mink seront sûrement septiques. Mais n’avez-vous jamais eu besoin de dupliquer du code pour vérifier qu’une notification est bien présente et contient le texte souhaité ?

Cette façon de faire répond exactement à ce cas. Vous pouvez définir un service PageObject (ou plutôt Element dans ce cas) qui se concentrera sur la vérification des différentes notifications possibles. Vous l’injecterez dans tous les contextes qui en ont besoin dans l’exécution d’une phrase. Cela évitera des phrases comme « je dois voir la notification… » qui ne sont pas forcément utiles.

Un autre bénéfice des PageObject est de pouvoir injecter le service dans plusieurs contextes et ainsi éviter la duplication de code.

à vous de jouer !

Qu’en pensez-vous ? Comment réalisez-vous vos tests fonctionnels ?