Files
Classeo/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php
Mathias STRASSER b9d9f48305 feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs
couches de protection contre les attaques par force brute.

Sécurité backend :
- Authentification JWT avec access token (15min) + refresh token (7j)
- Rotation automatique des refresh tokens avec détection de replay
- Rate limiting progressif par IP (délai Fibonacci après échecs)
- Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives
- Alerte email à l'utilisateur après blocage temporaire
- Isolation multi-tenant (un utilisateur ne peut se connecter que sur
  son établissement)

Frontend :
- Page de connexion avec feedback visuel des délais et erreurs
- Composant TurnstileCaptcha réutilisable
- Gestion d'état auth avec stockage sécurisé des tokens
- Tests E2E Playwright pour login, tenant isolation, et activation

Infrastructure :
- Configuration Symfony Security avec json_login + jwt
- Cache pools séparés (filesystem en test, Redis en prod)
- NullLoginRateLimiter pour environnement de test (évite blocage CI)
- Génération des clés JWT en CI après démarrage du backend
2026-02-01 14:43:12 +01:00

183 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ActivateAccountHandlerTest 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 Alpha';
private const string PASSWORD = 'SecurePass123';
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
private InMemoryActivationTokenRepository $tokenRepository;
private PasswordHasher $passwordHasher;
private Clock $clock;
private ActivateAccountHandler $handler;
protected function setUp(): void
{
$this->tokenRepository = new InMemoryActivationTokenRepository();
$this->passwordHasher = new class implements PasswordHasher {
#[Override]
public function hash(string $plainPassword): string
{
return '$argon2id$hashed_password';
}
#[Override]
public function verify(string $hashedPassword, string $plainPassword): bool
{
return true;
}
};
$this->clock = new class implements Clock {
public DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable('2026-01-16 10:00:00');
}
#[Override]
public function now(): DateTimeImmutable
{
return $this->now;
}
};
$this->handler = new ActivateAccountHandler(
$this->tokenRepository,
$this->passwordHasher,
$this->clock,
);
}
#[Test]
public function activateAccountSuccessfully(): void
{
$token = $this->createAndSaveToken();
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
$result = ($this->handler)($command);
self::assertInstanceOf(ActivateAccountResult::class, $result);
self::assertSame(self::USER_ID, $result->userId);
self::assertSame(self::EMAIL, $result->email);
self::assertSame(self::ROLE, $result->role);
self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword);
}
#[Test]
public function activateAccountValidatesButDoesNotConsumeToken(): void
{
// Handler only validates the token - consumption is deferred to the processor
// after successful user activation, so failed activations don't burn the token
$token = $this->createAndSaveToken();
$tokenValue = $token->tokenValue;
$command = new ActivateAccountCommand(
tokenValue: $tokenValue,
password: self::PASSWORD,
);
($this->handler)($command);
// Token should still exist and NOT be marked as used
$updatedToken = $this->tokenRepository->findByTokenValue($tokenValue);
self::assertNotNull($updatedToken);
self::assertFalse($updatedToken->isUsed());
}
#[Test]
public function activateAccountThrowsWhenTokenNotFound(): void
{
$command = new ActivateAccountCommand(
tokenValue: 'non-existent-token',
password: self::PASSWORD,
);
$this->expectException(ActivationTokenNotFoundException::class);
($this->handler)($command);
}
#[Test]
public function activateAccountThrowsWhenTokenExpired(): void
{
$token = $this->createAndSaveToken(
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
);
// Clock is set to 2026-01-16, token expires 2026-01-08
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
$this->expectException(ActivationTokenExpiredException::class);
($this->handler)($command);
}
#[Test]
public function activateAccountThrowsWhenTokenAlreadyUsed(): void
{
$token = $this->createAndSaveToken();
// Simulate a token that was already used (e.g., by the processor after successful activation)
$token->use($this->clock->now());
$this->tokenRepository->save($token);
$command = new ActivateAccountCommand(
tokenValue: $token->tokenValue,
password: self::PASSWORD,
);
// Should fail because token is already used
$this->expectException(ActivationTokenAlreadyUsedException::class);
($this->handler)($command);
}
private function createAndSaveToken(?DateTimeImmutable $createdAt = null): 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: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'),
);
$this->tokenRepository->save($token);
return $token;
}
}