From b8b638a5f917c44b5eda681c562065c1d2b5a37d Mon Sep 17 00:00:00 2001
From: Fabrice Gangler <fabrice.gangler@adullact.org>
Date: Tue, 25 Feb 2025 12:57:04 +0100
Subject: [PATCH] feat(signup): add controller + tests (v1)

---
 .../src/Controller/User/SignupController.php  | 153 ++++++++
 .../User/FunctionalTestCreateAccountTest.php  | 354 ++++++++++++++++++
 2 files changed, 507 insertions(+)
 create mode 100644 webapp/src/Controller/User/SignupController.php
 create mode 100644 webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php

diff --git a/webapp/src/Controller/User/SignupController.php b/webapp/src/Controller/User/SignupController.php
new file mode 100644
index 0000000..3e87649
--- /dev/null
+++ b/webapp/src/Controller/User/SignupController.php
@@ -0,0 +1,153 @@
+<?php
+
+/*
+ * This file is part of the Comptoir-du-Libre software.
+ * <https://gitlab.adullact.net/Comptoir/comptoir-du-libre>
+ *
+ * Copyright (c) ADULLACT   <https://adullact.org>
+ *               Association des Développeurs et Utilisateurs de Logiciels Libres
+ *               pour les Administrations et les Collectivités Territoriales
+ *
+ * Comptoir-du-Libre is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this software. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>.
+ */
+
+declare(strict_types=1);
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\User\SignupFormType;
+use App\Repository\UserRepository;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Form\FormFactoryInterface;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
+use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+#[\Symfony\Component\Routing\Attribute\Route(
+    requirements: [
+        '_locale' => 'en|fr',
+    ],
+)]
+class SignupController extends AbstractController
+{
+    #[Route(
+        path: '/{_locale}/account/signup',
+        name: 'app_account_signup_init',
+        methods: ['GET']
+    )]
+    public function signupInit(
+        Request $request,
+        TokenGeneratorInterface $tokenGenerator,
+        AuthorizationCheckerInterface $authChecker,
+    ): RedirectResponse {
+        if (true === $authChecker->isGranted('ROLE_USER')) {
+            return $this->redirectToRoute(route: 'app_user_account', status: Response::HTTP_SEE_OTHER);
+        }
+
+        $session = $request->getSession();
+        if ($session->has('signup_url_token') === true) {
+            $signupAntispamToken = $session->get('signup_url_token');
+        } else {
+            $signupAntispamToken = $tokenGenerator->generateToken();
+            $session->set('signup_url_token', $signupAntispamToken);
+            $session->set('signup_form_token', $tokenGenerator->generateToken());
+        }
+
+        return $this->redirectToRoute(
+            route: 'app_account_signup',
+            parameters: ['signupAntispamToken' => $signupAntispamToken],
+            status: Response::HTTP_SEE_OTHER
+        );
+    }
+
+    #[Route(
+        path: '/{_locale}/account/signup/t/{signupAntispamToken}',
+        name: 'app_account_signup',
+        methods: ['GET', 'POST']
+    )]
+    public function signup(
+        string $signupAntispamToken,
+        Request $request,
+        FormFactoryInterface $formFactory,
+        UserRepository $userRepository,
+        UserPasswordHasherInterface $userPasswordHasher,
+        TranslatorInterface $translator,
+        AuthorizationCheckerInterface $authChecker,
+    ): Response|RedirectResponse {
+        if (true === $authChecker->isGranted('ROLE_USER')) {
+             return $this->redirectToRoute(route: 'app_user_account', status: Response::HTTP_SEE_OTHER);
+        }
+
+        // Check session signup_*_token
+        $session = $request->getSession();
+        $validTokenInbUrl = true;
+        if ($session->has('signup_url_token') === false) {
+            $validTokenInbUrl = false;
+        } elseif ($session->get('signup_url_token') !== $signupAntispamToken) {
+            $validTokenInbUrl = false;
+            $session->remove('signup_url_token');
+            $session->remove('signup_form_token');
+        }
+
+        // If signup_url_token is not valid
+        if ($validTokenInbUrl === false) {
+            return $this->redirectToRoute(route: 'app_account_signup_init', status: Response::HTTP_SEE_OTHER);
+        }
+
+        // Generate form
+        $signupFormToken =  $session->get('signup_form_token');
+        $minPasswordLength = $this->getParameter('app.user_config.min_password_length');
+        $form = $formFactory->createNamed(
+            name: "signup_$signupFormToken",
+            type: SignupFormType::class,
+            options: ['min_password_length' => $minPasswordLength]
+        );
+        $form->handleRequest($request);
+        if ($form->isSubmitted() && $form->isValid()) {
+            $userEmail = $form->get('login')->getData();
+
+            // Check if the user already exists in the database
+            $user = $userRepository->findOneByEmail($form->get('login')->getData());
+            if ($user instanceof User) { // TODO improve this case
+                $session->remove('signup_url_token');  // Cleanup session data
+                $session->remove('signup_form_token'); // Cleanup session data
+                $errorMsgId =  'signup.error.account_already_exists';
+                $this->addFlash('danger', $translator->trans("$errorMsgId"));
+                return $this->redirectToRoute(route: 'app_account_login', status: Response::HTTP_SEE_OTHER);
+            }
+
+            // Add new user
+            $now = new \DateTimeImmutable();
+            $user = new User();
+            $user->setUpdatedAt($now);
+            $user->setCreatedAt($now);
+            $user->setLastPassowrdUpdate($now);
+            $user->setEmail($userEmail);
+            $user->setPassword($userPasswordHasher->hashPassword($user, $form->get('plainPassword')->getData()));
+            $userRepository->save($user, true);  // TODO add new User property [ enabled account ]
+
+            // Cleanup session data
+            $session->remove('signup_url_token');
+            $session->remove('signup_form_token');
+
+            // Redirect user and display success message
+            $this->addFlash('success', $translator->trans('signup.success'));
+            return $this->redirectToRoute('app_account_login', [], Response::HTTP_SEE_OTHER);
+        }
+
+        // Display registration form (first display or user errors in form)
+        return $this->render('user/signup.html.twig', ['signupForm' => $form->createView(),]);
+    }
+}
diff --git a/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php b/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php
new file mode 100644
index 0000000..0487f13
--- /dev/null
+++ b/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php
@@ -0,0 +1,354 @@
+<?php
+
+/*
+ * This file is part of the Comptoir-du-Libre software.
+ * <https://gitlab.adullact.net/Comptoir/comptoir-du-libre>
+ *
+ * Copyright (c) ADULLACT   <https://adullact.org>
+ *               Association des Développeurs et Utilisateurs de Logiciels Libres
+ *               pour les Administrations et les Collectivités Territoriales
+ *
+ * Comptoir-du-Libre is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this software. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Functional\User;
+
+use App\Repository\UserRepository;
+use App\Tests\Functional\TestHelperBreadcrumbTrait;
+use App\Tests\Functional\TestHelperFormTrait;
+use App\Tests\Functional\TestHelperTrait;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\DomCrawler\Crawler;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * @group gogogo
+ */
+class FunctionalTestCreateAccountTest extends WebTestCase
+{
+    use TestHelperTrait;
+    use TestHelperFormTrait;
+    use TestHelperBreadcrumbTrait;
+
+    private int $minPasswordLength;
+    private string $userEmailWhoNeverAddedToDatabase;
+
+    protected function setUp(): void
+    {
+        $this->userEmailWhoNeverAddedToDatabase = 'please-do-no-add-to-database@example.org';
+        $this->minPasswordLength = (int) $_ENV['WEBAPP_USER_CONFIG_MIN_PASSWORD_LENGTH'];
+    }
+
+
+    /**
+     * Check user account haas not been created
+     */
+    private function checkUserAccountHasNotBeenCreated(
+        string $userEmail,
+    ): void {
+        $userRepository = static::getContainer()->get(UserRepository::class);
+        $this->assertNull($userRepository->findOneByEmail($userEmail));
+        $this->assertNull($this->getMailerEvent(0)); // check no email was send to end user
+    }
+
+    /**
+     * Get valid data for SignUp form
+     *
+     * @return string[]
+     */
+    private function getValidDataForSignupForm(
+        string $htmlFormName,
+        string $userEmail,
+    ): array {
+        $email = $userEmail;
+        $password = '0123456789_ABCD';
+        return [
+            $htmlFormName . '[email]' => "$email",
+            $htmlFormName . '[plainPassword][first]' => "$password",
+            $htmlFormName . '[plainPassword][second]' => "$password",
+        ];
+    }
+
+    /**
+     * Send SignUp form request, with wrong data
+     */
+    private function sendRequestSignupFormWithWrongData(
+        array $formDataWithWrongData,
+        KernelBrowser $kernelBrowser
+    ): Crawler {
+        $crawler = $kernelBrowser->submitForm(
+            button: 'public_signupForm_submit',
+            fieldValues: $formDataWithWrongData
+        );
+        $this->assertRouteSame("app_account_signup");
+        $this->assertResponseStatusCodeSame(Response::HTTP_OK); // HTTP status code = 200
+        $this->assertSelectorTextSame('h1', 'Create your account');
+//      $this->assertSelectorTextSame('div.alert-danger', 'The form contains some errors, as described below.');
+        return $crawler;
+    }
+
+    /**
+     * Load an empty SignUp form
+     * and if necessary test all form fields
+     */
+    private function loadEmptySignUpForm(
+        bool $enableAssertions = false,
+    ): KernelBrowser {
+        $locale = 'en';
+        $kernelBrowser = static::createClient();
+        $crawler = $kernelBrowser->request('GET', "/$locale/account/signup");
+        $session = $kernelBrowser->getRequest()->getSession();
+        $sessionSignupUrlToken = $session->get('signup_url_token');
+        $sessionSignupFormToken = $session->get('signup_form_token');
+        $realFormUrl =  "/$locale/account/signup/t/$sessionSignupUrlToken";
+        $htmlFormName = "signup_$sessionSignupFormToken";
+        if ($enableAssertions === true) {
+            $this->assertRouteSame("app_account_signup_init");
+            $this->assertNotNull($sessionSignupUrlToken);
+            $this->assertNotNull($sessionSignupFormToken);
+            $this->assertResponseStatusCodeSame(
+                expectedCode: Response::HTTP_SEE_OTHER,
+                message: 'Bad HTTP code response'
+            );
+            $this->assertResponseHeaderSame(
+                headerName: "Location",
+                expectedValue: "$realFormUrl",
+                message: "Bad redirect location. Tested URL: /$locale/account/signup"
+            );
+        }
+
+        $crawler = $kernelBrowser->request('GET', "$realFormUrl");
+        if ($enableAssertions === true) {
+            $this->assertRouteSame("app_account_signup");
+            $this->assertResponseStatusCodeSame(Response::HTTP_OK); // HTTP status code = 200
+            $this->assertSelectorTextSame('h1', 'Create your account');
+
+            // Test all form fields
+            $this->checkAttribute(crawler: $crawler, cssFilter: "form[name=$htmlFormName]", attributesExpected: []);
+            $minPasswordLength = $this->minPasswordLength;
+            $this->checkFormField(
+                crawler: $crawler,
+                htmlFormName: $htmlFormName,
+                fieldIdsuffix: 'email', // see: HONEYPOT_FIELD_NAME
+                fieldAttributes: [
+                    'type' => 'email',
+                    'tabindex' => '-1',
+                    'aria-hidden' => 'true',
+                    'class' => 'visually-hidden form-control',
+                ],
+                notAllowedFieldAttributes: ['required',],
+                labelText: 'Email address',
+            );
+            $this->checkFormField(
+                crawler: $crawler,
+                htmlFormName: $htmlFormName,
+                fieldIdsuffix: 'login', // see: EMAIL_FIELD_NAME
+                fieldAttributes: [
+                    'type' => 'email',
+                    'required' => 'required'
+                ],
+                labelText: 'Email address',
+                helpText: 'Enter your email address, and we will send you a link to activate your account.'
+            );
+            $this->checkFormField(
+                crawler: $crawler,
+                htmlFormName: $htmlFormName,
+                fieldIdsuffix: 'plainPassword_first',
+                fieldAttributes: [
+                    'type' => 'password',
+                    'required' => 'required',
+                    'autocomplete' => 'new-password',
+                    'minlength' => "$minPasswordLength",
+                    'maxlength' => "4096",
+                ],
+                labelText: 'Your password',
+                helpText: "Please enter your password. It should be at least $minPasswordLength characters."
+            );
+            $this->checkFormField(
+                crawler: $crawler,
+                htmlFormName: $htmlFormName,
+                fieldIdsuffix: 'plainPassword_second',
+                fieldAttributes: [
+                    'type' => 'password',
+                    'required' => 'required',
+                    'autocomplete' => 'new-password',
+                    'minlength' => "$minPasswordLength",
+                    'maxlength' => "4096",
+                ],
+                labelText: 'Repeat password',
+                helpText: 'Please repeat your password.'
+            );
+            $this->checkAttribute(
+                crawler: $crawler,
+                cssFilter: "button#public_signupForm_submit",
+                attributesExpected: ['_text' => 'Create your account'],
+            );
+
+            // HTML content checks breadcrumb
+            $breadcrumb = [  '/en/account/signup' => 'Sign up',];
+            $this->checkHasValidBreadcrumb($crawler, $breadcrumb, "en");
+        }
+        return $kernelBrowser;
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     */
+    public function testAnonymousUserIsRedirectedToSignUpPageWithSessionTokenInUrl(): void
+    {
+        $locale = 'en';
+        $kernelBrowser = static::createClient();
+        $crawler = $kernelBrowser->request('GET', "/$locale/account/signup");
+        $session = $kernelBrowser->getRequest()->getSession();
+        $sessionSignupUrlToken = $session->get('signup_url_token');
+        $sessionSignupFormToken = $session->get('signup_form_token');
+        $this->assertNotNull($sessionSignupUrlToken);
+        $this->assertNotNull($sessionSignupFormToken);
+        $this->assertResponseStatusCodeSame(expectedCode: Response::HTTP_SEE_OTHER, message: 'Bad HTTP code response');
+        $this->assertResponseHeaderSame(
+            headerName: "Location",
+            expectedValue: "/$locale/account/signup/t/$sessionSignupUrlToken",
+            message: "Bad redirect location. Tested URL: /$locale/account/signup"
+        );
+    }
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     * @group NoConnectedUser
+     * @group NoConnectedUser_SignUp
+     */
+    public function testAnonymousUserCanDisplaySignUpForm(): void
+    {
+        $this->loadEmptySignUpForm(enableAssertions: true);
+    }
+
+    /////////////////////////////////////////////////////////////////////////
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     * @group NoConnectedUser
+     * @group NoConnectedUser_SignUp
+     * @group NoConnectedUser_SignUp_With_Wrong_Data
+     */
+    public function testAnonymousUserCanNotSendSignUpFormWithoutEmailField(): void
+    {
+        $userEmail = $this->userEmailWhoNeverAddedToDatabase;
+        $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false);
+        $session = $kernelBrowser->getRequest()->getSession();
+        $htmlFormName = "signup_" . $session->get('signup_form_token');
+        $this->commonCheckerWhenFormIsSentWithMissingOrEmptyField(
+            kernelBrowser: $kernelBrowser,
+            validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail),
+            formName: "$htmlFormName",
+            fieldName: 'login', // see: EMAIL_FIELD_NAME
+            invalidFeedback: 'This field is mandatory',
+            methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData'
+        );
+        $this->checkUserAccountHasNotBeenCreated($userEmail);
+    }
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     * @group NoConnectedUser
+     * @group NoConnectedUser_SignUp
+     * @group NoConnectedUser_SignUp_With_Wrong_Data
+     */
+    public function testAnonymousUserCanNotSendSignUpFormWithInvalidFormatEmail(): void
+    {
+        $userEmail = $this->userEmailWhoNeverAddedToDatabase;
+        $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false);
+        $session = $kernelBrowser->getRequest()->getSession();
+        $htmlFormName = "signup_" . $session->get('signup_form_token');
+        $validFormData = $this->getValidDataForSignupForm($htmlFormName, $userEmail);
+        $fieldName = 'login'; // see: EMAIL_FIELD_NAME
+        foreach ($this->getListOfEmailsInInvalidFormat() as $notValidEmail) {
+            $formDataWithWrongData = $validFormData;
+            $formDataWithWrongData["${htmlFormName}[${fieldName}]"] = $notValidEmail;
+            $crawler = $this->sendRequestSignupFormWithWrongData(
+                formDataWithWrongData: $formDataWithWrongData,
+                kernelBrowser: $kernelBrowser
+            );
+            $this->commonCheckerIfFormFieldIsInvalid(
+                crawler: $crawler,
+                cssFilterOfInvadFormField: "#${htmlFormName}_${fieldName}",
+                cssFilterOfInvalidFeedback: '.invalid-feedback',
+                invalidFeedback: 'Please enter a valid email address.',
+            );
+            $this->checkUserAccountHasNotBeenCreated($userEmail);
+        }
+    }
+
+    /////////////////////////////////////////////////////////////////////////
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     * @group NoConnectedUser
+     * @group NoConnectedUser_SignUp
+     * @group NoConnectedUser_SignUp_With_Wrong_Data
+     */
+    public function testAnonymousUserCanNotSendSignUpFormWithoutSimilarPasswordFields(): void
+    {
+        $userEmail = $this->userEmailWhoNeverAddedToDatabase;
+        $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false);
+        $session = $kernelBrowser->getRequest()->getSession();
+        $htmlFormName = "signup_" . $session->get('signup_form_token');
+        $this->commonCheckWhenFormIsSentWithoutSimilarNewPasswordFields(
+            kernelBrowser: $kernelBrowser,
+            validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail),
+            formName: "$htmlFormName",
+            fieldName: 'plainPassword',
+            minPasswordLength: $this->minPasswordLength,
+            methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData',
+            invalidFeedback: 'The password fields must match.'
+        );
+        $this->checkUserAccountHasNotBeenCreated($userEmail);
+    }
+
+    /**
+     * @group Form
+     * @group CreateAccount
+     * @group CreateAccount_Form
+     * @group NoConnectedUser
+     * @group NoConnectedUser_SignUp
+     * @group NoConnectedUser_SignUp_With_Wrong_Data
+     */
+    public function testAnonymousUserCanNotSendSignUpFormWithTooSmallPassword(): void
+    {
+        $userEmail = $this->userEmailWhoNeverAddedToDatabase;
+        $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false);
+        $session = $kernelBrowser->getRequest()->getSession();
+        $htmlFormName = "signup_" . $session->get('signup_form_token');
+        $this->commonCheckWhenFormIsSentWithTooSmallRepeatPassword(
+            kernelBrowser: $kernelBrowser,
+            validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail),
+            formName: "$htmlFormName",
+            fieldName: 'plainPassword',
+            minPasswordLength: $this->minPasswordLength,
+            methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData',
+        );
+        $this->checkUserAccountHasNotBeenCreated($userEmail);
+    }
+    ////////////////////////////////////////////////////////////////////////////////////////
+    ////////////////////////////////////////////////////////////////////////////////////////
+}
-- 
GitLab