feat: Activation de compte utilisateur avec validation token

L'inscription Classeo se fait via invitation : un admin crée un compte,
l'utilisateur reçoit un lien d'activation par email pour définir son
mot de passe. Ce flow sécurisé évite les inscriptions non autorisées
et garantit que seuls les utilisateurs légitimes accèdent au système.

Points clés de l'implémentation :
- Tokens d'activation à usage unique stockés en cache (Redis/filesystem)
- Validation du consentement parental pour les mineurs < 15 ans (RGPD)
- L'échec d'activation ne consume pas le token (retry possible)
- Users dans un cache séparé sans TTL (pas d'expiration)
- Hot reload en dev (FrankenPHP sans mode worker)

Story: 1.3 - Inscription et activation de compte
This commit is contained in:
2026-01-31 18:00:43 +01:00
parent 1fd256346a
commit c5e6c1d810
69 changed files with 5173 additions and 13 deletions

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* Tests for ActivateAccountProcessor focusing on token consumption behavior.
*
* Key invariant: Failed activations must NOT consume the token, allowing retries.
*/
final class ActivateAccountProcessorTest extends TestCase
{
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string EMAIL = 'user@example.com';
private const string ROLE = 'ROLE_PARENT';
private const string SCHOOL_NAME = 'École Test';
private const string PASSWORD = 'SecurePass123';
private const string HASHED_PASSWORD = '$argon2id$hashed';
private InMemoryActivationTokenRepository $tokenRepository;
private Clock $clock;
protected function setUp(): void
{
$this->tokenRepository = new InMemoryActivationTokenRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-01-16 10:00:00');
}
};
}
#[Test]
public function tokenRemainsValidWhenUserNotFound(): void
{
// Arrange: Create a valid token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
// Create processor with a UserRepository that throws UserNotFoundException
$processor = $this->createProcessorWithMissingUser();
$input = new ActivateAccountInput();
$input->tokenValue = $tokenValue;
$input->password = self::PASSWORD;
// Act: Try to activate (should fail because user not found)
try {
$processor->process($input, new Post());
self::fail('Expected NotFoundHttpException to be thrown');
} catch (NotFoundHttpException $e) {
self::assertSame('Utilisateur introuvable.', $e->getMessage());
}
// Assert: Token should NOT be consumed - retry should be possible
$tokenAfterFailure = $this->tokenRepository->findByTokenValue($tokenValue);
self::assertNotNull($tokenAfterFailure, 'Token should still exist after failed activation');
self::assertFalse($tokenAfterFailure->isUsed(), 'Token should NOT be marked as used after failed activation');
}
#[Test]
public function tokenCanBeReusedAfterFailedActivation(): void
{
// Arrange: Create a valid token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
$processorWithMissingUser = $this->createProcessorWithMissingUser();
$input = new ActivateAccountInput();
$input->tokenValue = $tokenValue;
$input->password = self::PASSWORD;
// Act: First activation fails (user not found)
try {
$processorWithMissingUser->process($input, new Post());
} catch (NotFoundHttpException) {
// Expected
}
// Assert: Can call handler again with same token (retry scenario)
$handler = $this->createHandler();
$result = ($handler)(new \App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand(
tokenValue: $tokenValue,
password: self::PASSWORD,
));
// Should succeed - token was not burned
self::assertSame(self::USER_ID, $result->userId);
}
private function createAndSaveToken(): ActivationToken
{
$token = ActivationToken::generate(
userId: self::USER_ID,
email: self::EMAIL,
tenantId: TenantId::fromString(self::TENANT_ID),
role: self::ROLE,
schoolName: self::SCHOOL_NAME,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->tokenRepository->save($token);
return $token;
}
private function createHandler(): ActivateAccountHandler
{
$passwordHasher = new class implements PasswordHasher {
public function hash(string $plainPassword): string
{
return '$argon2id$hashed';
}
public function verify(string $hashedPassword, string $plainPassword): bool
{
return true;
}
};
return new ActivateAccountHandler(
$this->tokenRepository,
$passwordHasher,
$this->clock,
);
}
private function createProcessorWithMissingUser(): ActivateAccountProcessor
{
$handler = $this->createHandler();
// UserRepository that always throws UserNotFoundException
$userRepository = new class implements UserRepository {
public function save(\App\Administration\Domain\Model\User\User $user): void
{
}
public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User
{
return null;
}
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
{
return null;
}
public function get(UserId $id): \App\Administration\Domain\Model\User\User
{
throw UserNotFoundException::withId($id);
}
};
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
$eventBus = $this->createMock(MessageBusInterface::class);
return new ActivateAccountProcessor(
$handler,
$userRepository,
$this->tokenRepository,
$consentementPolicy,
$this->clock,
$eventBus,
);
}
}