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
221 lines
7.5 KiB
PHP
221 lines
7.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Domain\Model\User;
|
|
|
|
use App\Administration\Domain\Event\CompteActive;
|
|
use App\Administration\Domain\Event\CompteCreated;
|
|
use App\Administration\Domain\Exception\CompteNonActivableException;
|
|
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\StatutCompte;
|
|
use App\Administration\Domain\Model\User\User;
|
|
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class UserTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string SCHOOL_NAME = 'École Alpha';
|
|
|
|
private Clock $clock;
|
|
private ConsentementParentalPolicy $consentementPolicy;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->clock = new class implements Clock {
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-01-31 10:00:00');
|
|
}
|
|
};
|
|
|
|
$this->consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
|
}
|
|
|
|
#[Test]
|
|
public function creerCreatesUserWithPendingStatus(): void
|
|
{
|
|
$user = $this->createUser();
|
|
|
|
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
|
|
self::assertNull($user->hashedPassword);
|
|
self::assertNull($user->activatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function creerRecordsCompteCreatedEvent(): void
|
|
{
|
|
$user = $this->createUser();
|
|
|
|
$events = $user->pullDomainEvents();
|
|
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(CompteCreated::class, $events[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerSetsPasswordAndChangesStatusToActive(): void
|
|
{
|
|
$user = $this->createUser();
|
|
$hashedPassword = '$argon2id$hashed';
|
|
$activatedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
|
|
|
$user->activer($hashedPassword, $activatedAt, $this->consentementPolicy);
|
|
|
|
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
|
self::assertSame($hashedPassword, $user->hashedPassword);
|
|
self::assertEquals($activatedAt, $user->activatedAt);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerRecordsCompteActiveEvent(): void
|
|
{
|
|
$user = $this->createUser();
|
|
$user->pullDomainEvents();
|
|
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
|
|
$events = $user->pullDomainEvents();
|
|
self::assertCount(1, $events);
|
|
self::assertInstanceOf(CompteActive::class, $events[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerThrowsWhenStatusIsNotPending(): void
|
|
{
|
|
$user = $this->createUser();
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
|
|
$this->expectException(CompteNonActivableException::class);
|
|
|
|
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerThrowsForMinorWithoutConsent(): void
|
|
{
|
|
// Créer un utilisateur mineur (14 ans)
|
|
$user = User::creer(
|
|
email: new Email('eleve@example.com'),
|
|
role: Role::ELEVE,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
$this->expectException(CompteNonActivableException::class);
|
|
$this->expectExceptionMessage('consentement parental manquant');
|
|
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerSucceedsForMinorWithConsent(): void
|
|
{
|
|
// Créer un utilisateur mineur (14 ans)
|
|
$user = User::creer(
|
|
email: new Email('eleve@example.com'),
|
|
role: Role::ELEVE,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: new DateTimeImmutable('2012-06-15'),
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
// Enregistrer le consentement parental
|
|
$consentement = ConsentementParental::accorder(
|
|
parentId: 'parent-uuid',
|
|
eleveId: (string) $user->id,
|
|
at: new DateTimeImmutable('2026-01-20 10:00:00'),
|
|
ipAddress: '192.168.1.1',
|
|
);
|
|
$user->enregistrerConsentementParental($consentement);
|
|
|
|
// L'activation devrait maintenant réussir
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
|
|
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
|
}
|
|
|
|
#[Test]
|
|
public function activerSucceedsForAdultWithoutConsent(): void
|
|
{
|
|
// Créer un utilisateur adulte (16 ans)
|
|
$user = User::creer(
|
|
email: new Email('eleve@example.com'),
|
|
role: Role::ELEVE,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: new DateTimeImmutable('2010-01-01'), // 16 ans
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
// Pas de consentement nécessaire
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
|
|
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
|
}
|
|
|
|
#[Test]
|
|
public function peutSeConnecterReturnsTrueOnlyWhenActive(): void
|
|
{
|
|
$user = $this->createUser();
|
|
|
|
self::assertFalse($user->peutSeConnecter());
|
|
|
|
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
|
|
|
self::assertTrue($user->peutSeConnecter());
|
|
}
|
|
|
|
#[Test]
|
|
public function necessiteConsentementParentalReturnsTrueForMinor(): void
|
|
{
|
|
$user = User::creer(
|
|
email: new Email('eleve@example.com'),
|
|
role: Role::ELEVE,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
self::assertTrue($user->necessiteConsentementParental($this->consentementPolicy));
|
|
}
|
|
|
|
#[Test]
|
|
public function necessiteConsentementParentalReturnsFalseForAdult(): void
|
|
{
|
|
$user = User::creer(
|
|
email: new Email('parent@example.com'),
|
|
role: Role::PARENT,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: null, // Parents n'ont pas de date de naissance enregistrée
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
|
|
self::assertFalse($user->necessiteConsentementParental($this->consentementPolicy));
|
|
}
|
|
|
|
private function createUser(): User
|
|
{
|
|
return User::creer(
|
|
email: new Email('user@example.com'),
|
|
role: Role::PARENT,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: self::SCHOOL_NAME,
|
|
dateNaissance: null,
|
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
|
);
|
|
}
|
|
}
|