Files
Classeo/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.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

220 lines
6.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
use App\Administration\Domain\Event\ActivationTokenGenerated;
use App\Administration\Domain\Event\ActivationTokenUsed;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ActivationTokenTest 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';
#[Test]
public function generateCreatesTokenWithCorrectProperties(): void
{
$userId = self::USER_ID;
$email = self::EMAIL;
$tenantId = TenantId::fromString(self::TENANT_ID);
$role = self::ROLE;
$schoolName = self::SCHOOL_NAME;
$now = new DateTimeImmutable('2026-01-15 10:00:00');
$token = ActivationToken::generate(
userId: $userId,
email: $email,
tenantId: $tenantId,
role: $role,
schoolName: $schoolName,
createdAt: $now,
);
self::assertInstanceOf(ActivationTokenId::class, $token->id);
self::assertSame($userId, $token->userId);
self::assertSame($email, $token->email);
self::assertTrue($tenantId->equals($token->tenantId));
self::assertSame($role, $token->role);
self::assertSame($schoolName, $token->schoolName);
self::assertEquals($now, $token->createdAt);
self::assertFalse($token->isUsed());
}
#[Test]
public function generateRecordsActivationTokenGeneratedEvent(): void
{
$token = $this->createToken();
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]);
}
#[Test]
public function tokenValueIsUuidV4Format(): void
{
$token = $this->createToken();
self::assertMatchesRegularExpression(
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
$token->tokenValue,
);
}
#[Test]
public function expiresAtIs7DaysAfterCreation(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00');
$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,
);
self::assertEquals($expectedExpiration, $token->expiresAt);
}
#[Test]
public function isExpiredReturnsFalseWhenNotExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-20 10:00:00');
$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,
);
self::assertFalse($token->isExpired($checkAt));
}
#[Test]
public function isExpiredReturnsTrueWhenExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-25 10:00:00');
$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,
);
self::assertTrue($token->isExpired($checkAt));
}
#[Test]
public function isExpiredReturnsTrueAtExactExpirationMoment(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$checkAt = new DateTimeImmutable('2026-01-22 10:00:00');
$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,
);
self::assertTrue($token->isExpired($checkAt));
}
#[Test]
public function useMarksTokenAsUsed(): void
{
$token = $this->createToken();
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($usedAt);
self::assertTrue($token->isUsed());
self::assertEquals($usedAt, $token->usedAt);
}
#[Test]
public function useRecordsActivationTokenUsedEvent(): void
{
$token = $this->createToken();
$token->pullDomainEvents();
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($usedAt);
$events = $token->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ActivationTokenUsed::class, $events[0]);
}
#[Test]
public function useThrowsExceptionWhenTokenAlreadyUsed(): void
{
$token = $this->createToken();
$firstUse = new DateTimeImmutable('2026-01-16 10:00:00');
$token->use($firstUse);
$this->expectException(ActivationTokenAlreadyUsedException::class);
$secondUse = new DateTimeImmutable('2026-01-17 10:00:00');
$token->use($secondUse);
}
#[Test]
public function useThrowsExceptionWhenTokenExpired(): void
{
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
$usedAt = new DateTimeImmutable('2026-01-25 10:00:00');
$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,
);
$this->expectException(ActivationTokenExpiredException::class);
$token->use($usedAt);
}
private function createToken(): ActivationToken
{
return 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'),
);
}
}