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

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'),
);
}
}