Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes, emploi du temps, devoirs). Cela nécessite un lien formalisé entre le compte parent et le compte élève, géré par les administrateurs. Le lien est établi soit manuellement via l'interface d'administration, soit automatiquement lors de l'activation du compte parent lorsque l'invitation inclut un élève cible. Ce lien conditionne l'accès aux données scolaires de l'enfant (autorisations vérifiées par un voter dédié).
223 lines
7.2 KiB
PHP
223 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
|
|
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
|
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
|
|
use App\Administration\Application\Port\PasswordHasher;
|
|
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
|
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
|
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
|
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use Override;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ActivateAccountHandlerTest 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';
|
|
private const string PASSWORD = 'SecurePass123';
|
|
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
|
|
|
|
private InMemoryActivationTokenRepository $tokenRepository;
|
|
private PasswordHasher $passwordHasher;
|
|
private Clock $clock;
|
|
private ActivateAccountHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountCarriesStudentIdFromToken(): void
|
|
{
|
|
$studentId = '550e8400-e29b-41d4-a716-446655440099';
|
|
$token = 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'),
|
|
studentId: $studentId,
|
|
);
|
|
$this->tokenRepository->save($token);
|
|
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $token->tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
$result = ($this->handler)($command);
|
|
|
|
self::assertSame($studentId, $result->studentId);
|
|
}
|
|
|
|
#[Test]
|
|
public function activateAccountReturnsNullStudentIdWhenNotSet(): void
|
|
{
|
|
$token = $this->createAndSaveToken();
|
|
|
|
$command = new ActivateAccountCommand(
|
|
tokenValue: $token->tokenValue,
|
|
password: self::PASSWORD,
|
|
);
|
|
|
|
$result = ($this->handler)($command);
|
|
|
|
self::assertNull($result->studentId);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|