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,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
final class ActivationTokenIdTest extends TestCase
{
#[Test]
public function generateCreatesValidUuid(): void
{
$id = ActivationTokenId::generate();
self::assertInstanceOf(ActivationTokenId::class, $id);
self::assertTrue(Uuid::isValid((string) $id));
}
#[Test]
public function fromStringCreatesIdFromValidUuid(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id = ActivationTokenId::fromString($uuid);
self::assertSame($uuid, (string) $id);
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$id1 = ActivationTokenId::fromString($uuid);
$id2 = ActivationTokenId::fromString($uuid);
self::assertTrue($id1->equals($id2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$id1 = ActivationTokenId::generate();
$id2 = ActivationTokenId::generate();
self::assertFalse($id1->equals($id2));
}
}

View File

@@ -0,0 +1,219 @@
<?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\Infrastructure\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'),
);
}
}

View File

@@ -0,0 +1,220 @@
<?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\Infrastructure\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'),
);
}
}