Browse Source

Merge branch 'feature/V8-4183_new_cli_for_subdomain_addition' into develop

Olivier Massot 2 years ago
parent
commit
bf20aead5e

+ 0 - 24
doc/bindfile.md

@@ -1,24 +0,0 @@
-# Fichier Bind
-
-Le fichier "bind" rend possible la résolution des sous-domaines au niveau du serveur DNS (situé sur prod-back).
-Chaque fois qu'un nouveau sous-domaine est enregistré, ce fichier doit être mis à jour. Cependant, pour des questions de 
-droits, c'est l'utilisateur root qui doit procéder à cette mise à jour. 
-
-Voilà ce qu'il se passe lorsqu'une structure enregistre un nouveau sous-domaine depuis `prod-back`:
-
-1. Le logiciel ajoute le sous-domaine en question dans une nouvelle ligne du fichier tampon `/env/subdomain.txt`
-2. Un cron tourne toutes les 5 minutes et consomme le contenu de ce fichier avant de le vider
-3. Ce cron exécute le script `/env/add-subdomain`, qui met à jour le fichier bind `/etc/bind/zones/opentalent.fr.db`
-4. Une fois mis à jour le fichier bind, le cron exécuté sur prod-back (le serveur DNS) envoie par ssh le contenu du 
-   fichier subdomain.txt sur un même fichier `/env/subdomain.txt` situé sur le serveur `vpn` 
-5. Le même cron tourne toutes les 5 minutes sur le serveur vpn procède à son tour à la mise à jour de son fichier bind
-
-La mise à jour des deux fichiers bind est nécessaire, pour permettre la prise en compte des sous-domaines depuis
-l'intérieur ou l'extérieur du VPN.
-
-Lorsqu'un nouveau sous-domaine est enregistré depuis `prod-v2`:
-
-> TODO: à revoir
-
-1. Le logiciel remplit de la même façon un fichier `/env/subdomain.txt`
-2. La suite se déroule comme dans le premier scénario

+ 53 - 0
doc/subdomain.md

@@ -0,0 +1,53 @@
+# Gestion des sous domaines
+
+## Opérations de mise à jour
+
+La route /api/subdomains permet les opérations suivantes :
+
+* Get : Récupère les sous-domaines existants
+* Post : Créé un nouveau sous domaine, et l'active éventuellement (selon que la prop `active` soit true ou non)
+* Put : Permet l'activation d'un sous-domaine en passant la propriété `active=true`
+
+Une organisation est limitée à 3 sous-domaines. Un sous-domaine ne peut être modifié une fois créé, à l'exception de 
+son statut (actif / inactif)
+
+Quand un sous domaine est ajouté, il est ajouté au fichier bind des serveurs prod-back et vpn (cf. plus bas).
+
+Quand un sous domaine est activé, les opérations suivantes sont réalisées :
+
+* les autres sous-domaines de l'organisation sont désactivés, de manière à ne conserver qu'un seul sous-domaine actif à la fois
+* une commande de mise à jour est envoyée à typo3 via l'API Http
+* le nom de l'admin de l'organisation est mis à jour pour être de la forme 'admin<subdomain>'
+* un email de confirmation est envoyé à la structure
+
+## CLI
+
+On peut ajouter et activer un nouveau sous-domaine au moyen de la commande :
+
+    php bin/console ot:subdomain:add <organization-id> <subdomain>
+
+
+## Fichier Bind
+
+Le fichier "bind" rend possible la résolution des sous-domaines au niveau du serveur DNS (situé sur prod-back).
+Chaque fois qu'un nouveau sous-domaine est enregistré, ce fichier doit être mis à jour. Cependant, pour des questions de
+droits, c'est l'utilisateur root qui doit procéder à cette mise à jour.
+
+Voilà ce qu'il se passe lorsqu'une structure enregistre un nouveau sous-domaine depuis `prod-back`:
+
+1. Le logiciel ajoute le sous-domaine en question dans une nouvelle ligne du fichier tampon `/env/subdomain.txt`
+2. Un cron tourne toutes les 5 minutes et consomme le contenu de ce fichier avant de le vider
+3. Ce cron exécute le script `/env/add-subdomain`, qui met à jour le fichier bind `/etc/bind/zones/opentalent.fr.db`
+4. Une fois mis à jour le fichier bind, le cron exécuté sur prod-back (le serveur DNS) envoie par ssh le contenu du
+   fichier subdomain.txt sur un même fichier `/env/subdomain.txt` situé sur le serveur `vpn`
+5. Le même cron tourne toutes les 5 minutes sur le serveur vpn procède à son tour à la mise à jour de son fichier bind
+
+La mise à jour des deux fichiers bind est nécessaire, pour permettre la prise en compte des sous-domaines depuis
+l'intérieur ou l'extérieur du VPN.
+
+Lorsqu'un nouveau sous-domaine est enregistré depuis `prod-v2`:
+
+> TODO: à revoir
+
+1. Le logiciel remplit de la même façon un fichier `/env/subdomain.txt`
+2. La suite se déroule comme dans le premier scénario

+ 50 - 0
src/Commands/AddSubdomainCommand.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Commands;
+
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Typo3\SubdomainService;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Exception\InvalidArgumentException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand(
+    name: 'ot:subdomain:add',
+    description: 'Add a subdomain to the given organization and make it the active one'
+)]
+class AddSubdomainCommand extends Command
+{
+    public function __construct(
+        private readonly OrganizationRepository $organizationRepository,
+        private readonly SubdomainService $subdomainService
+    ) {
+        parent::__construct();
+    }
+
+    protected function configure()
+    {
+        $this->addArgument('organization-id', InputArgument::REQUIRED, 'Id of the organization');
+        $this->addArgument('subdomain', InputArgument::REQUIRED, 'The new active subdomain');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $organizationId = $input->getArgument('organization-id');
+        if (!is_numeric($organizationId)) {
+            throw new InvalidArgumentException('Invalid organization id : ' . $organizationId);
+        }
+        $organization = $this->organizationRepository->find((int)$organizationId);
+
+        $subdomainValue = $input->getArgument('subdomain');
+
+        $output->writeln("Setting up a new subdomain for organization " . $organizationId . " : " . $subdomainValue);
+
+        $this->subdomainService->addNewSubdomain($organization, $subdomainValue, true);
+
+        $output->writeln("New subdomain added and activated");
+        return Command::SUCCESS;
+    }
+}

+ 13 - 0
src/Repository/Access/AccessRepository.php

@@ -64,6 +64,19 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
             ->getOneOrNullResult();
     }
 
+    /**
+     * Retourne l'access administrateur de cette organisation
+     *
+     * @param Organization $organization
+     * @return Access
+     */
+    public function findAdminAccess(Organization $organization): Access {
+        return $this->findOneBy([
+            'adminAccess' => 1,
+            'organization' => $organization
+        ]);
+    }
+
     /**
      * @param Access $access
      * @return mixed

+ 0 - 102
src/Service/OnChange/Organization/OnSubdomainChange.php

@@ -1,102 +0,0 @@
-<?php
-
-namespace App\Service\OnChange\Organization;
-
-use App\Entity\Organization\Organization;
-use App\Entity\Organization\Subdomain;
-use App\Message\Command\MailerCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
-use App\Service\Access\Utils as AccessUtils;
-use App\Service\Mailer\Model\SubdomainChangeModel;
-use App\Service\MailHub;
-use App\Service\OnChange\OnChangeContext;
-use App\Service\OnChange\OnChangeDefault;
-use App\Service\Organization\Utils as OrganizationUtils;
-use App\Service\Typo3\BindFileService;
-use App\Tests\Service\OnChange\Organization\OnSubdomainChangeTest;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Messenger\MessageBusInterface;
-use Symfony\Bundle\SecurityBundle\Security;
-
-class OnSubdomainChange extends OnChangeDefault
-{
-    public function __construct(
-        private OrganizationUtils $organizationUtils,
-        private BindFileService $bindFileService,
-        private MessageBusInterface $messageBus,
-        private Security $security,
-        private EntityManagerInterface $em
-    ) {}
-
-    /** @noinspection PhpParameterNameChangedDuringInheritanceInspection */
-    public function validate($subdomain, OnChangeContext $context): void {
-        // Ensure we do not exceed the limit of 3 subdomains per organization
-        if (
-            $context->isPostRequest() &&
-            count($subdomain->getOrganization()->getSubdomains()) >= 3
-        ) {
-            throw new \RuntimeException('This organization has already registered 3 subdomains');
-        }
-    }
-
-    /** @noinspection PhpParameterNameChangedDuringInheritanceInspection */
-    public function beforeChange($subdomain, OnChangeContext $context): void
-    {
-        // Ensure it is the only active subdomain of this organization by disabling other organization subdomains
-        if ($subdomain->isActive()) {
-            foreach ($subdomain->getOrganization()->getSubdomains() as $other) {
-                if ($other !== $subdomain && $other->isActive()) {
-                    $other->setActive(false);
-                }
-            }
-        }
-    }
-
-    /** @noinspection PhpParameterNameChangedDuringInheritanceInspection */
-    public function onChange($subdomain, OnChangeContext $context): void {
-        // Register into the BindFile
-        if ($context->isPostRequest()) {
-            $this->bindFileService->registerSubdomain($subdomain->getSubdomain());
-        }
-
-        // A new subdomain is active
-        // /!\ This has to be executed after everything has been persisted
-        if ($subdomain->isActive() && !($context->previousData() && $context->previousData()->isActive())) {
-            // TODO: comprendre pourquoi ce refresh est indispensable pour que l'organisation soit à jour
-            $this->em->refresh($subdomain->getOrganization());
-
-            // Update the typo3 website (asynchronously with messenger)
-            $this->messageBus->dispatch(
-                new Typo3UpdateCommand($subdomain->getOrganization()->getId())
-            );
-
-            // Envoi d'un email
-            $this->sendEmailAfterSubdomainChange($subdomain);
-        }
-    }
-
-    /**
-     * @param Subdomain $subdomain
-     * @see OnSubdomainChangeTest::testSendEmailAfterSubdomainChange()
-     */
-    public function sendEmailAfterSubdomainChange(Subdomain $subdomain): void
-    {
-        $this->messageBus->dispatch(
-            new MailerCommand($this->getMailModel($subdomain))
-        );
-    }
-
-    /**
-     * @param Subdomain $subdomain
-     * @return SubdomainChangeModel
-     * @see OnSubdomainChangeTest::testGetMailModel()
-     */
-    public function getMailModel(Subdomain $subdomain): SubdomainChangeModel
-    {
-        return (new SubdomainChangeModel())
-            ->setSenderId($this->security->getUser()->getId())
-            ->setOrganizationId($subdomain->getOrganization()->getId())
-            ->setSubdomainId($subdomain->getId())
-            ->setUrl($this->organizationUtils->getOrganizationWebsite($subdomain->getOrganization()));
-    }
-}

+ 0 - 1
src/Service/Organization/Utils.php

@@ -178,7 +178,6 @@ class Utils
      * @see https://ressources.opentalent.fr/display/SPEC/Preferences#Preferences-Siteinternet
      *
      * @param Organization $organization
-     * @param Organization $organization
      * @return mixed
      */
     public function getOrganizationWebsite(Organization $organization): mixed

+ 216 - 0
src/Service/Typo3/SubdomainService.php

@@ -0,0 +1,216 @@
+<?php
+
+namespace App\Service\Typo3;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Subdomain;
+use App\Message\Command\MailerCommand;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Organization\SubdomainRepository;
+use App\Service\Mailer\Model\SubdomainChangeModel;
+use App\Service\Organization\Utils as OrganizationUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Console\Exception\InvalidArgumentException;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+/**
+ * Service de gestion des sous-domaines des utilisateurs
+ */
+class SubdomainService
+{
+    // Max number of subdomains that an organization can own
+    const MAX_SUBDOMAINS_NUMBER = 3;
+
+    // Validation regex for subdomains
+    const RX_VALIDATE_SUBDOMAIN = '/^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$/';
+
+    public function __construct(
+        private readonly SubdomainRepository $subdomainRepository,
+        private readonly EntityManagerInterface $em,
+        private readonly MessageBusInterface $messageBus,
+        private readonly OrganizationUtils $organizationUtils,
+        private readonly BindFileService $bindFileService,
+        private readonly AccessRepository $accessRepository
+    ) {}
+
+    /**
+     * Is the organization allowed to register a new subdomain
+     *
+     * @param Organization $organization
+     * @return bool
+     */
+    public function canRegisterNewSubdomain(Organization $organization): bool {
+        return count($organization->getSubdomains()) < self::MAX_SUBDOMAINS_NUMBER;
+    }
+
+    /**
+     * Is the input a valid value for a subdomain
+     *
+     * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
+     * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
+     * @see https://www.rfc-editor.org/rfc/rfc1123#section-2.1
+     *
+     * @param string $subdomainValue
+     * @return bool
+     */
+    public function isValidSubdomain(string $subdomainValue): bool
+    {
+        return (bool)preg_match(self::RX_VALIDATE_SUBDOMAIN, $subdomainValue);
+    }
+
+    /**
+     * Register a new subdomain for the organization
+     * Is $activate is true, makes this new subdomain the active one too.
+     *
+     * @param Organization $organization
+     * @param string $subdomainValue
+     * @param bool $activate
+     * @return Subdomain
+     */
+    public function addNewSubdomain(
+        Organization $organization,
+        string $subdomainValue,
+        bool $activate = false
+    ): Subdomain {
+
+        if (!$this->isValidSubdomain($subdomainValue)) {
+            throw new \RuntimeException("Not a valid subdomain");
+        }
+
+        if (!$this->canRegisterNewSubdomain($organization)) {
+            throw new \RuntimeException("This organization can not register new subdomains");
+        }
+
+        // Vérifie que le sous-domaine n'est pas déjà utilisé
+        if ($this->subdomainRepository->findBy(['subdomain' => $subdomainValue])) {
+            throw new \RuntimeException('This subdomain is already registered');
+        }
+
+        $subdomain = new Subdomain();
+        $subdomain->setSubdomain($subdomainValue);
+        $subdomain->setOrganization($organization);
+        $subdomain->setActive(false);
+
+        $this->em->persist($subdomain);
+        $this->em->flush();
+
+        // Register into the BindFile (takes up to 5min to take effect)
+        $this->bindFileService->registerSubdomain($subdomain->getSubdomain());
+
+        if ($activate) {
+            $subdomain = $this->activateSubdomain($subdomain);
+        }
+
+        return $subdomain;
+    }
+
+    /**
+     * Makes the $subdomain the active one for the organization.
+     *
+     * @param Subdomain $subdomain
+     * @return void
+     */
+    public function activateSubdomain(Subdomain $subdomain): Subdomain {
+        if ($subdomain->isActive()) {
+            throw new \RuntimeException('The subdomain is already active');
+        }
+        if (!$subdomain->getId()) {
+            throw new \RuntimeException('Can not activate a non-persisted subdomain');
+        }
+
+        $subdomain = $this->setOrganizationActiveSubdomain($subdomain);
+
+        $this->renameAdminUserToMatchSubdomain($subdomain);
+
+        // Update the typo3 website (asynchronously with messenger)
+        $this->updateTypo3Website($subdomain->getOrganization());
+
+        // Send confirmation email
+        $this->sendConfirmationEmail($subdomain);
+
+        return $subdomain;
+    }
+
+    /**
+     * The subdomain becomes the only active subdomain of its organization.
+     * New state is persisted is database.
+     *
+     * @param Subdomain $subdomain
+     * @return Subdomain
+     */
+    protected function setOrganizationActiveSubdomain(Subdomain $subdomain): Subdomain {
+        foreach ($subdomain->getOrganization()->getSubdomains() as $other) {
+            if ($other !== $subdomain && $other->isActive()) {
+                $other->setActive(false);
+            }
+        }
+        $subdomain->setActive(true);
+
+        // TODO: comprendre pourquoi ce refresh est indispensable pour que l'organisation soit à jour
+        $this->em->flush();
+        $this->em->refresh($subdomain->getOrganization());
+
+        return $subdomain;
+    }
+
+    /**
+     * Rename the admin user of the organization to match the given subdomain
+     *
+     * @param Subdomain $subdomain
+     * @return void
+     */
+    protected function renameAdminUserToMatchSubdomain(Subdomain $subdomain): void {
+        $adminAccess = $this->accessRepository->findAdminAccess($subdomain->getOrganization());
+
+        $adminAccess->getPerson()->setUsername('admin' . $subdomain->getSubdomain());
+        $this->em->flush();
+    }
+
+    /**
+     * Trigger an update of the typo3 organization's website
+     *
+     * @param $organization
+     * @return void
+     */
+    protected function updateTypo3Website($organization): void
+    {
+        $this->messageBus->dispatch(
+            new Typo3UpdateCommand($organization->getId())
+        );
+    }
+
+    /**
+     * Build the data model for the confirmation email
+     *
+     * @param Subdomain $subdomain
+     * @return SubdomainChangeModel
+     */
+    protected function getMailModel(Subdomain $subdomain): SubdomainChangeModel {
+        $adminAccess = $this->accessRepository->findAdminAccess($subdomain->getOrganization());
+
+        return (new SubdomainChangeModel())
+            ->setSenderId($adminAccess->getId())
+            ->setOrganizationId($subdomain->getOrganization()->getId())
+            ->setSubdomainId($subdomain->getId())
+            ->setUrl($this->organizationUtils->getOrganizationWebsite($subdomain->getOrganization()));
+    }
+
+    /**
+     * Send the confirmation email to the organization after a new subdomain has been activated
+     *
+     * @param Subdomain $subdomain
+     * @return void
+     */
+    protected function sendConfirmationEmail(Subdomain $subdomain): void {
+        // TODO: revoir quel sender par défaut
+        $model = $this->getMailModel($subdomain);
+
+        // Envoi d'un email
+        $this->messageBus->dispatch(
+            new MailerCommand($model)
+        );
+    }
+}

+ 4 - 2
src/State/Processor/EntityProcessor.php

@@ -30,13 +30,15 @@ class EntityProcessor implements ProcessorInterface
     public function setProcessorInterface(ProcessorInterface $persistProcessor): void {$this->persistProcessor = $persistProcessor;}
 
     /**
-     * Persist l'entité et déclenche les différents hooks de la classe OnChangeInterface définie par le data persister
+     * Persiste l'entité et déclenche les différents hooks de la classe OnChangeInterface définie par le data persister
      *
      * @param mixed $data
+     * @param Operation $operation
+     * @param array $uriVariables
      * @param array $context
      * @return object
      */
-    public function process($data, Operation $operation, array $uriVariables = [], array $context = [])
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object
     {
         if($operation instanceof Delete){
             throw new \RuntimeException('not supported', 500);

+ 43 - 8
src/State/Processor/Organization/SubdomainProcessor.php

@@ -3,19 +3,54 @@ declare(strict_types=1);
 
 namespace App\State\Processor\Organization;
 
-use App\State\Processor\EntityProcessor;
-use App\Service\OnChange\Organization\OnSubdomainChange;
-use JetBrains\PhpStorm\Pure;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\Put;
+use ApiPlatform\State\ProcessorInterface;
+use App\Entity\Organization\Subdomain;
+use App\Repository\Organization\SubdomainRepository;
+use App\Service\Typo3\SubdomainService;
+use Doctrine\ORM\EntityManagerInterface;
 
 /**
  * Custom Processor gérant la resource Subdomain
  */
-class SubdomainProcessor extends EntityProcessor
+class SubdomainProcessor implements ProcessorInterface
 {
-    #[Pure]
     public function __construct(
-        OnSubdomainChange $onChange
-    ) {
-        parent::__construct($onChange);
+        private readonly SubdomainService $subdomainService
+    ) {}
+
+    /**
+     * Persiste l'entité et déclenche les différents hooks de la classe OnChangeInterface définie par le data persister
+     *
+     * @param Subdomain $data
+     * @param Operation $operation
+     * @param array $uriVariables
+     * @param array $context
+     * @return object
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) {
+        if($operation instanceof Delete){
+            throw new \RuntimeException('not supported', 500);
+        }
+
+        if ($operation instanceof Post) {
+            // Create a new subdomain
+            $subdomain = $this->subdomainService->addNewSubdomain(
+                $data->getOrganization(),
+                $data->getSubdomain(),
+                $data->isActive()
+            );
+        } else if ($operation instanceof Put && $data->isActive()) {
+            // Activate a subdomain
+            $data->setActive(false); // On triche : c'est le service qui va activer ce sous-domaine, pas le processor
+            $subdomain = $this->subdomainService->activateSubdomain($data);
+        } else {
+            throw new \RuntimeException('not supported', 500);
+        }
+
+        return $subdomain;
     }
 }

+ 2 - 2
templates/emails/subdomain.html.twig

@@ -14,7 +14,7 @@
     </p>
 
     <p>
-        Votre site sera dè lors accessible à l'adresse suivante :
+        Votre site sera dès lors accessible à l'adresse suivante :
         <br/>
         <a href="{{url}}">{{url}}</a>
     </p>
@@ -28,4 +28,4 @@
         </p>
     {% endif %}
 
-{% endblock %}
+{% endblock %}

+ 0 - 62
tests/Service/ApiLegacy/ApiLegacyRequestServiceTest.php

@@ -79,68 +79,6 @@ class ApiLegacyRequestServiceTest extends TestCase
         );
     }
 
-    /**
-     * @see ApiLegacyRequestService::request()
-     */
-    public function testRequestNoToken(): void
-    {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
-
-        $this->security->method('getToken')->willReturn(null);
-
-        $this->expectException(HttpException::class);
-        $this->expectExceptionMessage('Request error : Invalid security token');
-
-        $api1RequestService->request('GET', '/an/url');
-    }
-
-    /**
-     * @see ApiLegacyRequestService::request()
-     */
-    public function testRequestNullToken(): void
-    {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
-
-        $token = $this->getMockBuilder(NullToken::class)->disableOriginalConstructor()->getMock();
-
-        $this->security->method('getToken')->willReturn($token);
-
-        $this->expectException(HttpException::class);
-        $this->expectExceptionMessage('Request error : Invalid security token');
-
-        $api1RequestService->request('GET', '/an/url');
-    }
-
-    /**
-     * @see ApiLegacyRequestService::request()
-     */
-    public function testRequestInvalidToken(): void
-    {
-        $api1RequestService = $this
-            ->getMockBuilder(ApiLegacyRequestService::class)
-            ->setConstructorArgs([$this->apiLegacyClient, $this->security, $this->jwtManager])
-            ->setMethodsExcept(['request'])
-            ->getMock();
-
-        $token = $this->getMockBuilder(UsernamePasswordToken::class)->disableOriginalConstructor()->getMock();
-        $token->method('getUser')->willReturn(null);
-
-        $this->security->method('getToken')->willReturn($token);
-
-        $this->expectException(HttpException::class);
-        $this->expectExceptionMessage('Request error : Invalid security token');
-
-        $api1RequestService->request('GET', '/an/url');
-    }
-
     /**
      * @see ApiLegacyRequestService::request()
      */

+ 1 - 1
tests/Service/File/Storage/ApiLegacyStorageTest.php

@@ -27,7 +27,7 @@ class ApiLegacyStorageTest extends TestCase
         $apiLegacyRequestService
             ->expects(self::once())
             ->method('getContent')
-            ->with('api/files/123/download')
+            ->with('_internal/secure/files/123')
             ->willReturn('xyz');
 
         $result = $apiLegacyStorageTest->read($file);

+ 0 - 281
tests/Service/OnChange/Organization/OnSubdomainChangeTest.php

@@ -1,281 +0,0 @@
-<?php
-
-namespace App\Tests\Service\OnChange\Organization;
-
-use App\Entity\Access\Access;
-use App\Entity\Organization\Organization;
-use App\Entity\Organization\Subdomain;
-use App\Message\Command\MailerCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
-use App\Service\Mailer\Model\SubdomainChangeModel;
-use App\Service\OnChange\OnChangeContext;
-use App\Service\OnChange\Organization\OnSubdomainChange;
-use App\Service\Organization\Utils as OrganizationUtils;
-use App\Service\Typo3\BindFileService;
-use Doctrine\Common\Collections\ArrayCollection;
-use Doctrine\ORM\EntityManagerInterface;
-use PHPUnit\Framework\MockObject\MockObject;
-use PHPUnit\Framework\TestCase;
-use Symfony\Component\Messenger\Envelope;
-use Symfony\Component\Messenger\MessageBusInterface;
-use Symfony\Bundle\SecurityBundle\Security;
-
-class OnSubdomainChangeTest extends TestCase
-{
-    private OrganizationUtils $organizationUtils;
-    private Security $security;
-    private BindFileService $bindFileService;
-    private MessageBusInterface $messageBus;
-    private EntityManagerInterface $entityManager;
-
-    public function setUp():void
-    {
-        $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
-        $this->security = $this->getMockBuilder(Security::class)->disableOriginalConstructor()->getMock();
-        $this->bindFileService = $this->getMockBuilder(BindFileService::class)->disableOriginalConstructor()->getMock();
-        $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
-        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
-    }
-
-    private function makeOnSubdomainChangeMock(string $methodName): MockObject | OnSubdomainChange {
-        return $this->getMockBuilder(OnSubdomainChange::class)
-            ->setConstructorArgs([$this->organizationUtils, $this->bindFileService, $this->messageBus, $this->security, $this->entityManager])
-            ->setMethodsExcept([$methodName])
-            ->getMock();
-    }
-
-    /**
-     * @see OnSubdomainChange::validate()
-     */
-    public function testValidateIsOk(): void
-    {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('validate');
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('isPostRequest')->willReturn(true);
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->expects(self::once())->method('getSubdomains')->willReturn(new ArrayCollection([1,2]));
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->expects(self::once())->method('getOrganization')->willReturn($organization);
-
-        $onSubdomainChange->validate($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::validate()
-     */
-    public function testValidateIsPutRequest(): void
-    {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('validate');
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('isPostRequest')->willReturn(false);
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->expects(self::never())->method('getOrganization');
-
-        $onSubdomainChange->validate($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::validate()
-     */
-    public function testValidateMaxReached(): void
-    {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('validate');
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('isPostRequest')->willReturn(true);
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->expects(self::once())->method('getSubdomains')->willReturn(new ArrayCollection([1,2,3]));
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->expects(self::once())->method('getOrganization')->willReturn($organization);
-
-        $this->expectException(\RuntimeException::class);
-        $onSubdomainChange->validate($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::beforeChange()
-     */
-    public function testBeforeChangeActivated(): void
-    {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('beforeChange');
-
-        // Le sous-domaine qu'on vient d'activer
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->method('isActive')->willReturn(true);
-
-        // Son état précédent
-        $previousData = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $previousData->method('isActive')->willReturn(false);
-
-        // Le sous domaine qui était actif jusqu'ici, et que le OnChange devrait désactiver
-        $otherSubdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $otherSubdomain->method('isActive')->willReturn(true);
-        $otherSubdomain->expects(self::once())->method('setActive')->with(false);
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->expects(self::once())->method('getSubdomains')->willReturn(new ArrayCollection([$subdomain, $otherSubdomain]));
-
-        $subdomain->method('getOrganization')->willReturn($organization);
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('previousData')->willReturn($previousData);
-        $context->method('isPutRequest')->willReturn(true);
-        $context->method('isPostRequest')->willReturn(false);
-
-        $onSubdomainChange->beforeChange($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::onChange()
-     */
-    public function testOnChangeNoChange(): void
-    {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('onChange');
-
-        $this->bindFileService->expects(self::never())->method('registerSubdomain');
-        $this->messageBus->expects(self::never())->method('dispatch');
-        $onSubdomainChange->expects(self::never())->method('sendEmailAfterSubdomainChange');
-        $this->entityManager->expects(self::never())->method('refresh');
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-
-        $onSubdomainChange->onChange($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::onChange()
-     */
-    public function testOnChangeActivated(): void {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('onChange');
-
-        $this->bindFileService->expects(self::never())->method('registerSubdomain');
-        $this->messageBus
-            ->expects(self::once())
-            ->method('dispatch')
-            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
-            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
-
-        $onSubdomainChange->expects(self::once())->method('sendEmailAfterSubdomainChange');
-
-        // Le sous-domaine qu'on vient d'activer
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->method('isActive')->willReturn(true);
-
-        // Son état précédent
-        $previousData = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $previousData->method('isActive')->willReturn(false);
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->method('getId')->willReturn(1);
-
-        $subdomain->method('getOrganization')->willReturn($organization);
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('previousData')->willReturn($previousData);
-        $context->method('isPutRequest')->willReturn(true);
-        $context->method('isPostRequest')->willReturn(false);
-
-        $this->entityManager->expects(self::once())->method('refresh')->with($organization);
-
-        $onSubdomainChange->onChange($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::onChange()
-     */
-    public function testOnChangeCreated(): void {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('onChange');
-
-        $this->bindFileService->expects(self::once())->method('registerSubdomain');
-        $this->messageBus
-            ->expects(self::once())
-            ->method('dispatch')
-            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
-            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
-
-        $onSubdomainChange->expects(self::once())->method('sendEmailAfterSubdomainChange');
-
-        // Le sous-domaine qu'on vient d'activer
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->method('isActive')->willReturn(true);
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->method('getId')->willReturn(1);
-
-        $subdomain->method('getOrganization')->willReturn($organization);
-
-        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
-        $context->method('previousData')->willReturn(null);
-        $context->method('isPutRequest')->willReturn(false);
-        $context->method('isPostRequest')->willReturn(true);
-
-        $onSubdomainChange->onChange($subdomain, $context);
-    }
-
-    /**
-     * @see OnSubdomainChange::sendEmailAfterSubdomainChange()
-     */
-    public function testSendEmailAfterSubdomainChange(): void {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('sendEmailAfterSubdomainChange');
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomainChangeModel = $this->getMockBuilder(SubdomainChangeModel::class)->disableOriginalConstructor()->getMock();
-
-        $onSubdomainChange
-            ->expects(self::once())
-            ->method('getMailModel')
-            ->with($subdomain)
-            ->willReturn($subdomainChangeModel);
-
-        $this->messageBus
-            ->expects(self::once())
-            ->method('dispatch')
-            ->with(self::isInstanceOf(MailerCommand::class))
-            ->willReturn(new Envelope(new MailerCommand($subdomainChangeModel)));
-
-        $onSubdomainChange->sendEmailAfterSubdomainChange($subdomain);
-    }
-
-    /**
-     * @see OnSubdomainChange::getMailModel()
-     */
-    public function testGetMailModel(): void {
-        $onSubdomainChange = $this->makeOnSubdomainChangeMock('getMailModel');
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $organization->expects(self::once())->method('getId')->willReturn(1);
-
-        $this->organizationUtils
-            ->expects(self::once())
-            ->method('getOrganizationWebsite')
-            ->with($organization)
-            ->willReturn('mysubdomain.opentalent.fr');
-
-        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
-        $access->expects(self::once())->method('getId')->willReturn(1);
-        $this->security
-            ->expects(self::once())
-            ->method('getUser')
-            ->willReturn($access);
-
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->expects(self::exactly(2))->method('getOrganization')->willReturn($organization);
-        $subdomain->expects(self::once())->method('getId')->willReturn(1);
-
-        $mailerModel = $onSubdomainChange->getMailModel($subdomain);
-
-        $this->assertInstanceOf(SubdomainChangeModel::class, $mailerModel);
-        $this->assertEquals($mailerModel->getSenderId(), 1);
-        $this->assertEquals($mailerModel->getOrganizationId(), 1);
-        $this->assertEquals($mailerModel->getSubdomainId(), 1);
-        $this->assertEquals($mailerModel->getUrl(), 'mysubdomain.opentalent.fr');
-    }
-}

+ 431 - 0
tests/Service/Typo3/SubdomainServiceTest.php

@@ -0,0 +1,431 @@
+<?php
+
+namespace App\Tests\Service\Typo3;
+
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Subdomain;
+use App\Entity\Person\Person;
+use App\Message\Command\MailerCommand;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Organization\SubdomainRepository;
+use App\Service\Mailer\Model\SubdomainChangeModel;
+use App\Service\Organization\Utils as OrganizationUtils;
+use App\Service\Typo3\BindFileService;
+use App\Service\Typo3\SubdomainService;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Messenger\Envelope;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+class TestableSubdomainService extends SubdomainService {
+    public function setOrganizationActiveSubdomain(Subdomain $subdomain): Subdomain {
+        return parent::setOrganizationActiveSubdomain($subdomain);
+    }
+
+    public function renameAdminUserToMatchSubdomain(Subdomain $subdomain): void {
+        parent::renameAdminUserToMatchSubdomain($subdomain);
+    }
+
+    public function updateTypo3Website($organization): void {
+        parent::updateTypo3Website($organization);
+    }
+
+    public function getMailModel(Subdomain $subdomain): SubdomainChangeModel
+    {
+        return parent::getMailModel($subdomain);
+    }
+
+    public function sendConfirmationEmail(Subdomain $subdomain): void {
+        parent::sendConfirmationEmail($subdomain);
+    }
+}
+class SubdomainServiceTest extends TestCase
+{
+    private SubdomainRepository $subdomainRepository;
+    private OrganizationUtils $organizationUtils;
+    private BindFileService $bindFileService;
+    private MessageBusInterface $messageBus;
+    private EntityManagerInterface $entityManager;
+    private AccessRepository $accessRepository;
+
+    public function setUp():void
+    {
+        $this->subdomainRepository = $this->getMockBuilder(SubdomainRepository::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
+        $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
+        $this->bindFileService = $this->getMockBuilder(BindFileService::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function makeOnSubdomainChangeMock(string $methodName): MockObject | TestableSubdomainService {
+        return $this->getMockBuilder(TestableSubdomainService::class)
+            ->setConstructorArgs([
+                $this->subdomainRepository,
+                $this->entityManager,
+                $this->messageBus,
+                $this->organizationUtils,
+                $this->bindFileService,
+                $this->accessRepository
+            ])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    /**
+     * @see SubdomainService::canRegisterNewSubdomain()
+     */
+    public function testCanRegisterNewSubdomainTrue(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('canRegisterNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new ArrayCollection([1, 2]));
+
+        $this->assertTrue($subdomainService->canRegisterNewSubdomain($organization));
+    }
+
+    /**
+     * @see SubdomainService::canRegisterNewSubdomain()
+     */
+    public function testCanRegisterNewSubdomainFalse(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('canRegisterNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new ArrayCollection([1, 2, 3]));
+
+        $this->assertFalse($subdomainService->canRegisterNewSubdomain($organization));
+    }
+
+    /**
+     * @see SubdomainService::isValidSubdomain()
+     */
+    public function testIsValidSubdomain(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('isValidSubdomain');
+
+        $this->assertTrue($subdomainService->isValidSubdomain('abcd'));
+        $this->assertTrue($subdomainService->isValidSubdomain('abcdefgh'));
+        $this->assertTrue($subdomainService->isValidSubdomain('abcd-efgh'));
+        $this->assertTrue($subdomainService->isValidSubdomain('123'));
+        $this->assertTrue($subdomainService->isValidSubdomain('a'));
+
+        $this->assertFalse($subdomainService->isValidSubdomain('_abc'));
+        $this->assertFalse($subdomainService->isValidSubdomain('abc-'));
+        $this->assertFalse($subdomainService->isValidSubdomain(str_repeat('abcdef', 20)));
+    }
+
+    public function testAddNewSubdomain(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('addNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomainService->expects(self::once())->method('isValidSubdomain')->with('sub')->willReturn(True);
+        $subdomainService->expects(self::once())->method('canRegisterNewSubdomain')->with($organization)->willReturn(True);
+        $this->subdomainRepository->expects(self::once())->method('findBy')->with(['subdomain' => 'sub'])->willReturn(0);
+
+        $this->entityManager->expects(self::once())->method('persist');
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $this->bindFileService->expects(self::once())->method('registerSubdomain')->with('sub');
+
+        // Subdomain is not activated by default
+        $subdomainService->expects(self::never())->method('activateSubdomain');
+
+        $subdomain = $subdomainService->addNewSubdomain($organization, 'sub');
+
+        $this->assertEquals($subdomain->getOrganization(), $organization);
+        $this->assertEquals($subdomain->getSubdomain(), 'sub');
+        $this->assertFalse($subdomain->isActive());
+    }
+
+    public function testAddNewSubdomainInvalid(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('addNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomainService->expects(self::once())->method('isValidSubdomain')->with('_sub')->willReturn(False);
+        $subdomainService->expects(self::never())->method('canRegisterNewSubdomain')->with($organization)->willReturn(True);
+        $this->subdomainRepository->expects(self::never())->method('findBy')->with(['subdomain' => '_sub'])->willReturn(0);
+
+        $this->entityManager->expects(self::never())->method('persist');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->bindFileService->expects(self::never())->method('registerSubdomain')->with('_sub');
+        $subdomainService->expects(self::never())->method('activateSubdomain');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Not a valid subdomain');
+
+        $subdomainService->addNewSubdomain($organization, '_sub');
+    }
+
+    public function testAddNewSubdomainMaxReached(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('addNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomainService->expects(self::once())->method('isValidSubdomain')->with('_sub')->willReturn(true);
+        $subdomainService->expects(self::once())->method('canRegisterNewSubdomain')->with($organization)->willReturn(false);
+        $this->subdomainRepository->expects(self::never())->method('findBy')->with(['subdomain' => '_sub'])->willReturn(0);
+
+        $this->entityManager->expects(self::never())->method('persist');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->bindFileService->expects(self::never())->method('registerSubdomain')->with('_sub');
+        $subdomainService->expects(self::never())->method('activateSubdomain');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('This organization can not register new subdomains');
+
+        $subdomainService->addNewSubdomain($organization, '_sub');
+    }
+
+    public function testAddNewSubdomainExisting(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('addNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomainService->expects(self::once())->method('isValidSubdomain')->with('sub')->willReturn(true);
+        $subdomainService->expects(self::once())->method('canRegisterNewSubdomain')->with($organization)->willReturn(true);
+        $this->subdomainRepository->expects(self::once())->method('findBy')->with(['subdomain' => 'sub'])->willReturn(1);
+
+        $this->entityManager->expects(self::never())->method('persist');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->bindFileService->expects(self::never())->method('registerSubdomain')->with('sub');
+        $subdomainService->expects(self::never())->method('activateSubdomain');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('This subdomain is already registered');
+
+        $subdomainService->addNewSubdomain($organization, 'sub');
+    }
+
+    public function testAddNewSubdomainAndActivate(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('addNewSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomainService->expects(self::once())->method('isValidSubdomain')->with('sub')->willReturn(true);
+        $subdomainService->expects(self::once())->method('canRegisterNewSubdomain')->with($organization)->willReturn(true);
+        $this->subdomainRepository->expects(self::once())->method('findBy')->with(['subdomain' => 'sub'])->willReturn(0);
+
+        $subdomainService->expects(self::once())->method('activateSubdomain');
+
+        $subdomainService->addNewSubdomain($organization, 'sub', true);
+    }
+
+    public function testActivateSubdomain(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('activateSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $initialSubdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $initialSubdomain->method('getId')->willReturn(1);
+        $initialSubdomain->method('isActive')->willReturn(false);
+
+        $activatedSubdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $activatedSubdomain->method('getId')->willReturn(1);
+        $activatedSubdomain->method('isActive')->willReturn(true);
+        $activatedSubdomain->method('getOrganization')->willReturn($organization);
+
+        $subdomainService
+            ->expects(self::once())
+            ->method('setOrganizationActiveSubdomain')
+            ->with($initialSubdomain)
+            ->willReturn($activatedSubdomain);
+
+        $subdomainService
+            ->expects(self::once())
+            ->method('renameAdminUserToMatchSubdomain')
+            ->with($activatedSubdomain);
+
+        $subdomainService
+            ->expects(self::once())
+            ->method('updateTypo3Website')
+            ->with($organization);
+
+        $subdomainService
+            ->expects(self::once())
+            ->method('sendConfirmationEmail')
+            ->with($activatedSubdomain);
+
+        $result = $subdomainService->activateSubdomain($initialSubdomain);
+
+        $this->assertEquals($result, $activatedSubdomain);
+    }
+
+    public function testActivateSubdomainAlreadyActive(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('activateSubdomain');
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $subdomain->method('getId')->willReturn(1);
+        $subdomain->method('isActive')->willReturn(true);
+
+        $subdomainService->expects(self::never())->method('setOrganizationActiveSubdomain');
+        $subdomainService->expects(self::never())->method('renameAdminUserToMatchSubdomain');
+        $subdomainService->expects(self::never())->method('updateTypo3Website');
+        $subdomainService->expects(self::never())->method('sendConfirmationEmail');
+
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('The subdomain is already active');
+
+        $subdomainService->activateSubdomain($subdomain);
+    }
+
+    public function testActivateSubdomainNotPersisted(): void
+    {
+        $subdomainService = $this->makeOnSubdomainChangeMock('activateSubdomain');
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $subdomain->method('getId')->willReturn(null);
+        $subdomain->method('isActive')->willReturn(false);
+
+        $subdomainService->expects(self::never())->method('setOrganizationActiveSubdomain');
+        $subdomainService->expects(self::never())->method('renameAdminUserToMatchSubdomain');
+        $subdomainService->expects(self::never())->method('updateTypo3Website');
+        $subdomainService->expects(self::never())->method('sendConfirmationEmail');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Can not activate a non-persisted subdomain');
+
+        $subdomainService->activateSubdomain($subdomain);
+    }
+
+    public function testSetOrganizationActiveSubdomain(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('setOrganizationActiveSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $subdomain->method('getId')->willReturn(1);
+        $subdomain->method('isActive')->willReturn(false);
+
+        $otherSubdomain1 = $this->getMockBuilder(Subdomain::class)->getMock();
+        $otherSubdomain1->method('getId')->willReturn(2);
+        $otherSubdomain1->method('isActive')->willReturn(true);
+
+        $otherSubdomain2 = $this->getMockBuilder(Subdomain::class)->getMock();
+        $otherSubdomain2->method('getId')->willReturn(3);
+        $otherSubdomain2->method('isActive')->willReturn(false);
+
+        $organization->method('getSubdomains')->willReturn(new ArrayCollection([$otherSubdomain1, $otherSubdomain2]));
+        $subdomain->method('getOrganization')->willReturn($organization);
+
+        // The active subdomain is deactivated
+        $otherSubdomain1->expects(self::once())->method('setActive')->with(false);
+
+        // The inactive subdomain is not modified
+        $otherSubdomain2->expects(self::never())->method('setActive');
+
+        // The new subdomain is activated
+        $subdomain->expects(self::once())->method('setActive')->with(true);
+
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->entityManager->expects(self::once())->method('refresh')->with($organization);
+
+        $result = $subdomainService->setOrganizationActiveSubdomain($subdomain);
+
+        $this->assertEquals($result, $subdomain);
+    }
+
+    public function testRenameAdminUserToMatchSubdomain(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('renameAdminUserToMatchSubdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+
+        $this->accessRepository
+            ->method('findAdminAccess')
+            ->with($organization)
+            ->willReturn($access);
+
+        $subdomain->method('getSubdomain')->willReturn('sub');
+        $subdomain->method('getOrganization')->willReturn($organization);
+
+        $access->method('getPerson')->willReturn($person);
+
+        $person->expects(self::once())->method('setUsername')->with('adminsub');
+
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $subdomainService->renameAdminUserToMatchSubdomain($subdomain);
+    }
+
+    public function testUpdateTypo3Website(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('updateTypo3Website');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
+            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+
+        $subdomainService->updateTypo3Website($organization);
+    }
+
+    public function testGetMailModel(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('getMailModel');
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getId')->willReturn(1);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->method('getOrganization')->willReturn($organization);
+        $subdomain->method('getId')->willReturn(1);
+
+        $this->accessRepository
+            ->method('findAdminAccess')
+            ->with($organization)
+            ->willReturn($access);
+
+        $this->organizationUtils
+            ->expects(self::once())
+            ->method('getOrganizationWebsite')
+            ->with($organization)
+            ->willReturn('mysubdomain.opentalent.fr');
+
+        $mailerModel = $subdomainService->getMailModel($subdomain);
+
+        $this->assertInstanceOf(SubdomainChangeModel::class, $mailerModel);
+        $this->assertEquals(1, $mailerModel->getSenderId());
+        $this->assertEquals(1, $mailerModel->getOrganizationId());
+        $this->assertEquals(1, $mailerModel->getSubdomainId());
+        $this->assertEquals('mysubdomain.opentalent.fr', $mailerModel->getUrl());
+    }
+
+    public function testSendConfirmationEmail(): void {
+        $subdomainService = $this->makeOnSubdomainChangeMock('sendConfirmationEmail');
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $subdomainChangeModel = $this->getMockBuilder(SubdomainChangeModel::class)->getMock();
+
+        $subdomainService->method('getMailModel')->willReturn($subdomainChangeModel);
+
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(MailerCommand::class))
+            ->willReturn(new Envelope(new MailerCommand($subdomainChangeModel)));
+
+        $subdomainService->sendConfirmationEmail($subdomain);
+    }
+}