+
+ ✓
+
+
+
Votre compte est activé !
+
+
Bonjour,
+
+
Nous vous confirmons que votre compte Classeo a été activé avec succès.
+
+
+
Email : {{ email }}
+
Rôle : {{ role }}
+
+
+
Vous pouvez maintenant vous connecter à Classeo pour accéder à toutes les fonctionnalités disponibles.
+
+
+ Se connecter
+
+
+
Conseils de sécurité :
+
+ - Ne partagez jamais votre mot de passe
+ - Déconnectez-vous après utilisation sur un ordinateur partagé
+ - Contactez votre établissement en cas de problème d'accès
+
+
+
+
+
+
diff --git a/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php
new file mode 100644
index 0000000..83cb50d
--- /dev/null
+++ b/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php
@@ -0,0 +1,182 @@
+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;
+ }
+}
diff --git a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php
new file mode 100644
index 0000000..936113d
--- /dev/null
+++ b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php
@@ -0,0 +1,50 @@
+equals($id2));
+ }
+
+ #[Test]
+ public function equalsReturnsFalseForDifferentValue(): void
+ {
+ $id1 = ActivationTokenId::generate();
+ $id2 = ActivationTokenId::generate();
+
+ self::assertFalse($id1->equals($id2));
+ }
+}
diff --git a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php
new file mode 100644
index 0000000..7ec9594
--- /dev/null
+++ b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php
@@ -0,0 +1,219 @@
+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'),
+ );
+ }
+}
diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
new file mode 100644
index 0000000..f8ad842
--- /dev/null
+++ b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
@@ -0,0 +1,220 @@
+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'),
+ );
+ }
+}
diff --git a/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php b/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php
new file mode 100644
index 0000000..2744d08
--- /dev/null
+++ b/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php
@@ -0,0 +1,105 @@
+clock = new class implements Clock {
+ public function now(): DateTimeImmutable
+ {
+ return new DateTimeImmutable('2026-01-31 10:00:00');
+ }
+ };
+
+ $this->policy = new ConsentementParentalPolicy($this->clock);
+ }
+
+ #[Test]
+ public function consentementRequisPourUtilisateurDe14Ans(): void
+ {
+ $dateNaissance = new DateTimeImmutable('2012-01-31');
+
+ self::assertTrue($this->policy->estRequis($dateNaissance));
+ }
+
+ #[Test]
+ public function consentementRequisPourUtilisateurDe10Ans(): void
+ {
+ $dateNaissance = new DateTimeImmutable('2016-01-31');
+
+ self::assertTrue($this->policy->estRequis($dateNaissance));
+ }
+
+ #[Test]
+ public function consentementNonRequisPourUtilisateurDe15Ans(): void
+ {
+ $dateNaissance = new DateTimeImmutable('2011-01-30');
+
+ self::assertFalse($this->policy->estRequis($dateNaissance));
+ }
+
+ #[Test]
+ public function consentementNonRequisPourUtilisateurDe16Ans(): void
+ {
+ $dateNaissance = new DateTimeImmutable('2010-01-31');
+
+ self::assertFalse($this->policy->estRequis($dateNaissance));
+ }
+
+ #[Test]
+ public function consentementNonRequisSiDateNaissanceNulle(): void
+ {
+ self::assertFalse($this->policy->estRequis(null));
+ }
+
+ #[Test]
+ #[DataProvider('agesBordureProvider')]
+ public function consentementRequisAuxAgesBordure(
+ string $dateNaissance,
+ bool $consentementRequis,
+ string $description,
+ ): void {
+ $result = $this->policy->estRequis(new DateTimeImmutable($dateNaissance));
+
+ self::assertSame($consentementRequis, $result, $description);
+ }
+
+ /**
+ * @return iterable