test: Ajouter les tests unitaires manquants (backend et frontend)
Couverture des processors (RefreshToken, RequestPasswordReset, ResetPassword, SwitchRole, UpdateUserRoles), des query handlers (HasGradesInPeriod, HasStudentsInClass), des messaging handlers (SendActivationConfirmation, SendPasswordResetEmail), et côté frontend des modules auth, roles, monitoring, types et E2E tokens.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\HasGradesInPeriod;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Application\Query\HasGradesInPeriod\HasGradesInPeriodHandler;
|
||||
use App\Administration\Application\Query\HasGradesInPeriod\HasGradesInPeriodQuery;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for HasGradesInPeriodHandler.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Delegates to GradeExistenceChecker port
|
||||
* - Returns boolean indicating grade presence
|
||||
* - Correctly passes tenantId, academicYearId, and periodSequence
|
||||
*/
|
||||
final class HasGradesInPeriodHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
#[Test]
|
||||
public function returnsTrueWhenGradesExist(): void
|
||||
{
|
||||
$checker = $this->createMock(GradeExistenceChecker::class);
|
||||
$checker->expects(self::once())
|
||||
->method('hasGradesInPeriod')
|
||||
->with(
|
||||
self::callback(static fn (TenantId $t) => (string) $t === self::TENANT_ID),
|
||||
self::callback(static fn (AcademicYearId $a) => (string) $a === self::ACADEMIC_YEAR_ID),
|
||||
1,
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$handler = new HasGradesInPeriodHandler($checker);
|
||||
|
||||
$query = new HasGradesInPeriodQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodSequence: 1,
|
||||
);
|
||||
|
||||
$result = ($handler)($query);
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsFalseWhenNoGrades(): void
|
||||
{
|
||||
$checker = $this->createMock(GradeExistenceChecker::class);
|
||||
$checker->method('hasGradesInPeriod')->willReturn(false);
|
||||
|
||||
$handler = new HasGradesInPeriodHandler($checker);
|
||||
|
||||
$query = new HasGradesInPeriodQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodSequence: 2,
|
||||
);
|
||||
|
||||
$result = ($handler)($query);
|
||||
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passesCorrectPeriodSequence(): void
|
||||
{
|
||||
$checker = $this->createMock(GradeExistenceChecker::class);
|
||||
$checker->expects(self::once())
|
||||
->method('hasGradesInPeriod')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::anything(),
|
||||
3,
|
||||
)
|
||||
->willReturn(false);
|
||||
|
||||
$handler = new HasGradesInPeriodHandler($checker);
|
||||
|
||||
$query = new HasGradesInPeriodQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodSequence: 3,
|
||||
);
|
||||
|
||||
($handler)($query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassHandler;
|
||||
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for HasStudentsInClassHandler.
|
||||
*
|
||||
* Currently returns 0 (stub) until the student module is available.
|
||||
* These tests document the expected behavior for when the implementation
|
||||
* is completed.
|
||||
*/
|
||||
final class HasStudentsInClassHandlerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function returnsZeroForAnyClass(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
|
||||
$query = new HasStudentsInClassQuery(
|
||||
classId: '550e8400-e29b-41d4-a716-446655440020',
|
||||
);
|
||||
|
||||
$result = ($handler)($query);
|
||||
|
||||
self::assertSame(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsIntegerType(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
|
||||
$query = new HasStudentsInClassQuery(
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
);
|
||||
|
||||
$result = ($handler)($query);
|
||||
|
||||
self::assertIsInt($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isConsistentAcrossMultipleCalls(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
$classId = '550e8400-e29b-41d4-a716-446655440022';
|
||||
|
||||
$result1 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
|
||||
$result2 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
|
||||
|
||||
self::assertSame($result1, $result2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Service\RefreshTokenManager;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
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\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\SessionRepository;
|
||||
use App\Administration\Infrastructure\Api\Processor\RefreshTokenProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\RefreshTokenInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\RefreshTokenOutput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUserFactory;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use DateTimeImmutable;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Tests for RefreshTokenProcessor.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Refresh token is read from HttpOnly cookie, not from body
|
||||
* - Token replay triggers family invalidation and cookie clearing
|
||||
* - Suspended users cannot refresh tokens
|
||||
* - Cross-tenant token usage is rejected
|
||||
*/
|
||||
final class RefreshTokenProcessorTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
private InMemoryRefreshTokenRepository $refreshTokenRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private RequestStack $requestStack;
|
||||
private JWTTokenManagerInterface $jwtManager;
|
||||
private TenantResolver $tenantResolver;
|
||||
private MessageBusInterface $eventBus;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
Request::setTrustedHosts(['^.*$']);
|
||||
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->requestStack = new RequestStack();
|
||||
$this->jwtManager = $this->createMock(JWTTokenManagerInterface::class);
|
||||
$this->tenantResolver = $this->createMock(TenantResolver::class);
|
||||
$this->eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNoRequestAvailable(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$this->expectExceptionMessage('Request not available');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenRefreshTokenCookieMissing(): void
|
||||
{
|
||||
$request = Request::create('/api/token/refresh', 'POST');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$this->expectExceptionMessage('Refresh token not found');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsNewJwtOnSuccessfulRefresh(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->jwtManager->method('create')->willReturn('new-jwt-token');
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
$output = $processor->process(new RefreshTokenInput(), new Post());
|
||||
|
||||
self::assertInstanceOf(RefreshTokenOutput::class, $output);
|
||||
self::assertSame('new-jwt-token', $output->token);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function setsRefreshTokenCookieOnSuccess(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->jwtManager->method('create')->willReturn('jwt');
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
|
||||
$cookie = $request->attributes->get('_refresh_token_cookie');
|
||||
self::assertNotNull($cookie, 'Refresh token cookie should be set in request attributes');
|
||||
self::assertSame('refresh_token', $cookie->getName());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenUserSuspended(): void
|
||||
{
|
||||
$user = $this->createAndSaveSuspendedUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Account is no longer active');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedOnTokenReplayDetection(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
$tokenString = $token->toTokenString();
|
||||
|
||||
// Simulate rotation: mark the token as rotated (beyond grace period)
|
||||
[$newToken, $rotatedToken] = $token->rotate(new DateTimeImmutable('2026-02-15 09:00:00'));
|
||||
$this->refreshTokenRepository->save($newToken);
|
||||
$this->refreshTokenRepository->save($rotatedToken);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $tokenString);
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->eventBus->expects(self::once())
|
||||
->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Session compromise detected');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsConflictOnTokenAlreadyRotatedInGracePeriod(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
$tokenString = $token->toTokenString();
|
||||
|
||||
// Simulate rotation within grace period (rotatedAt is close to now)
|
||||
[$newToken, $rotatedToken] = $token->rotate(new DateTimeImmutable('2026-02-15 09:59:50'));
|
||||
$this->refreshTokenRepository->save($newToken);
|
||||
$this->refreshTokenRepository->save($rotatedToken);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $tokenString);
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(ConflictHttpException::class);
|
||||
$this->expectExceptionMessage('Token already rotated');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedOnInvalidToken(): void
|
||||
{
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', 'invalid-not-base64!!!');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsCrossTenantTokenUsage(): void
|
||||
{
|
||||
$differentTenantId = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
|
||||
// Create token for a DIFFERENT tenant
|
||||
$token = RefreshToken::create(
|
||||
userId: $user->id,
|
||||
tenantId: $differentTenantId,
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest('TestBrowser/1.0'),
|
||||
issuedAt: new DateTimeImmutable('2026-02-15 09:00:00'),
|
||||
ttlSeconds: 86400,
|
||||
);
|
||||
$this->refreshTokenRepository->save($token);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'ecole-alpha.classeo.fr']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$tenantConfig = new TenantConfig(
|
||||
\App\Shared\Infrastructure\Tenant\TenantId::fromString(self::TENANT_ID),
|
||||
'ecole-alpha',
|
||||
'sqlite:///:memory:',
|
||||
);
|
||||
$this->tenantResolver->method('resolve')->willReturn($tenantConfig);
|
||||
$this->jwtManager->method('create')->willReturn('jwt');
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Invalid token for this tenant');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsUnknownHostInProduction(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'evil.example.com']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->tenantResolver->method('resolve')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('evil'));
|
||||
$this->jwtManager->method('create')->willReturn('jwt');
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('Invalid host for token refresh');
|
||||
|
||||
$processor->process(new RefreshTokenInput(), new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function skipsHostValidationForLocalhost(): void
|
||||
{
|
||||
$user = $this->createAndSaveActiveUser();
|
||||
$token = $this->createAndSaveRefreshToken($user);
|
||||
|
||||
$request = Request::create('/api/token/refresh', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$request->cookies->set('refresh_token', $token->toTokenString());
|
||||
$request->headers->set('User-Agent', 'TestBrowser/1.0');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->jwtManager->method('create')->willReturn('jwt');
|
||||
$this->tenantResolver->expects(self::never())->method('resolve');
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
$output = $processor->process(new RefreshTokenInput(), new Post());
|
||||
|
||||
self::assertSame('jwt', $output->token);
|
||||
}
|
||||
|
||||
private function createProcessor(): RefreshTokenProcessor
|
||||
{
|
||||
$refreshTokenManager = new RefreshTokenManager(
|
||||
$this->refreshTokenRepository,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$sessionRepository = new class implements SessionRepository {
|
||||
public function save(\App\Administration\Domain\Model\Session\Session $session, int $ttlSeconds): void
|
||||
{
|
||||
}
|
||||
|
||||
public function getByFamilyId(TokenFamilyId $familyId): \App\Administration\Domain\Model\Session\Session
|
||||
{
|
||||
throw new \App\Administration\Domain\Exception\SessionNotFoundException('');
|
||||
}
|
||||
|
||||
public function findByFamilyId(TokenFamilyId $familyId): ?\App\Administration\Domain\Model\Session\Session
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findAllByUserId(UserId $userId): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function delete(TokenFamilyId $familyId): void
|
||||
{
|
||||
}
|
||||
|
||||
public function deleteAllExcept(UserId $userId, TokenFamilyId $exceptFamilyId): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function updateActivity(TokenFamilyId $familyId, DateTimeImmutable $at, int $ttlSeconds): void
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
return new RefreshTokenProcessor(
|
||||
$refreshTokenManager,
|
||||
$this->jwtManager,
|
||||
$this->userRepository,
|
||||
$sessionRepository,
|
||||
$this->requestStack,
|
||||
new SecurityUserFactory(),
|
||||
$this->tenantResolver,
|
||||
$this->eventBus,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createAndSaveActiveUser(): User
|
||||
{
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PROF],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createAndSaveSuspendedUser(): User
|
||||
{
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PROF],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::SUSPENDU,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
blockedAt: new DateTimeImmutable('2026-02-10T10:00:00+00:00'),
|
||||
blockedReason: 'Comportement inapproprie',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createAndSaveRefreshToken(User $user): RefreshToken
|
||||
{
|
||||
$token = RefreshToken::create(
|
||||
userId: $user->id,
|
||||
tenantId: $user->tenantId,
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest('TestBrowser/1.0'),
|
||||
issuedAt: new DateTimeImmutable('2026-02-15 09:00:00'),
|
||||
ttlSeconds: 86400,
|
||||
);
|
||||
$this->refreshTokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\RequestPasswordReset\RequestPasswordResetHandler;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Infrastructure\Api\Processor\RequestPasswordResetProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\RequestPasswordResetOutput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\Store\InMemoryStore;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||
|
||||
/**
|
||||
* Tests for RequestPasswordResetProcessor.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Always returns success to prevent email enumeration
|
||||
* - Rate limited by email (silent) and IP (visible)
|
||||
* - Tenant is resolved from request host
|
||||
*/
|
||||
final class RequestPasswordResetProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryPasswordResetTokenRepository $tokenRepository;
|
||||
private RequestStack $requestStack;
|
||||
private TenantResolver $tenantResolver;
|
||||
private MessageBusInterface $eventBus;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
Request::setTrustedHosts(['^.*$']);
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-15 10:00:00');
|
||||
}
|
||||
};
|
||||
$this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock);
|
||||
$this->requestStack = new RequestStack();
|
||||
$this->tenantResolver = $this->createMock(TenantResolver::class);
|
||||
$this->eventBus = $this->createMock(MessageBusInterface::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSuccessOnValidRequest(): void
|
||||
{
|
||||
$this->saveUserInRepository('user@example.com');
|
||||
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$output = $processor->process($input, new Post());
|
||||
|
||||
self::assertInstanceOf(RequestPasswordResetOutput::class, $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function alwaysReturnsSuccessEvenForNonexistentEmail(): void
|
||||
{
|
||||
// No user saved in repository
|
||||
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'nonexistent@example.com';
|
||||
|
||||
$output = $processor->process($input, new Post());
|
||||
|
||||
self::assertInstanceOf(RequestPasswordResetOutput::class, $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenRequestNotAvailable(): void
|
||||
{
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Request not available');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenIpRateLimitExceeded(): void
|
||||
{
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$processor = $this->createProcessorWithRejectedIpLimiter();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$this->expectException(TooManyRequestsHttpException::class);
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSuccessSilentlyWhenEmailRateLimitExceeded(): void
|
||||
{
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
// Handler should NOT dispatch events since rate limited
|
||||
$this->eventBus->expects(self::never())->method('dispatch');
|
||||
|
||||
$processor = $this->createProcessorWithRejectedEmailLimiter();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$output = $processor->process($input, new Post());
|
||||
|
||||
self::assertInstanceOf(RequestPasswordResetOutput::class, $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolvesTenantFromHost(): void
|
||||
{
|
||||
$this->saveUserInRepository('user@example.com');
|
||||
|
||||
$tenantConfig = new TenantConfig(
|
||||
\App\Shared\Infrastructure\Tenant\TenantId::fromString(self::TENANT_ID),
|
||||
'ecole-alpha',
|
||||
'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'ecole-alpha.classeo.fr']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->tenantResolver->expects(self::once())
|
||||
->method('resolve')
|
||||
->with('ecole-alpha.classeo.fr')
|
||||
->willReturn($tenantConfig);
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function usesDefaultTenantForLocalhost(): void
|
||||
{
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'localhost']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->tenantResolver->expects(self::never())->method('resolve');
|
||||
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenTenantNotFound(): void
|
||||
{
|
||||
$request = Request::create('/api/password/forgot', 'POST', server: ['HTTP_HOST' => 'unknown.classeo.fr']);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$this->tenantResolver->method('resolve')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('unknown'));
|
||||
|
||||
$processor = $this->createProcessorWithAcceptingLimiters();
|
||||
|
||||
$input = new RequestPasswordResetInput();
|
||||
$input->email = 'user@example.com';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('non reconnu');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
private function saveUserInRepository(string $email): void
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email($email),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
}
|
||||
|
||||
private function createHandler(): RequestPasswordResetHandler
|
||||
{
|
||||
return new RequestPasswordResetHandler(
|
||||
$this->userRepository,
|
||||
$this->tokenRepository,
|
||||
$this->clock,
|
||||
$this->eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessorWithAcceptingLimiters(): RequestPasswordResetProcessor
|
||||
{
|
||||
return new RequestPasswordResetProcessor(
|
||||
$this->createHandler(),
|
||||
$this->requestStack,
|
||||
$this->tenantResolver,
|
||||
$this->createAcceptingLimiterFactory(),
|
||||
$this->createAcceptingLimiterFactory(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessorWithRejectedIpLimiter(): RequestPasswordResetProcessor
|
||||
{
|
||||
return new RequestPasswordResetProcessor(
|
||||
$this->createHandler(),
|
||||
$this->requestStack,
|
||||
$this->tenantResolver,
|
||||
$this->createAcceptingLimiterFactory(),
|
||||
$this->createRejectedLimiterFactory(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessorWithRejectedEmailLimiter(): RequestPasswordResetProcessor
|
||||
{
|
||||
return new RequestPasswordResetProcessor(
|
||||
$this->createHandler(),
|
||||
$this->requestStack,
|
||||
$this->tenantResolver,
|
||||
$this->createRejectedLimiterFactory(),
|
||||
$this->createAcceptingLimiterFactory(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createAcceptingLimiterFactory(): RateLimiterFactory
|
||||
{
|
||||
return new RateLimiterFactory(
|
||||
['id' => 'test_accept', 'policy' => 'fixed_window', 'limit' => 1000, 'interval' => '1 hour'],
|
||||
new InMemoryStorage(),
|
||||
new LockFactory(new InMemoryStore()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a RateLimiterFactory that rejects ANY key on first consume.
|
||||
*
|
||||
* Uses sliding_window with limit=1, pre-consumed for every possible key
|
||||
* by wrapping the factory to exhaust its limit before returning.
|
||||
*/
|
||||
private function createRejectedLimiterFactory(): RateLimiterFactory
|
||||
{
|
||||
$storage = new InMemoryStorage();
|
||||
$lockFactory = new LockFactory(new InMemoryStore());
|
||||
|
||||
$factory = new RateLimiterFactory(
|
||||
['id' => 'test_reject', 'policy' => 'fixed_window', 'limit' => 1, 'interval' => '1 hour'],
|
||||
$storage,
|
||||
$lockFactory,
|
||||
);
|
||||
|
||||
// Pre-exhaust for the keys the processor will use:
|
||||
// IP limiter uses client IP (127.0.0.1 from Request::create)
|
||||
// Email limiter uses "{tenantId}:{email}"
|
||||
$factory->create('127.0.0.1')->consume();
|
||||
$factory->create(self::TENANT_ID . ':user@example.com')->consume();
|
||||
|
||||
return $factory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\ResetPassword\ResetPasswordHandler;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Infrastructure\Api\Processor\ResetPasswordProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ResetPasswordInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\ResetPasswordOutput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPasswordResetTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\GoneHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Tests for ResetPasswordProcessor.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Token not found -> 400
|
||||
* - Token expired -> 410
|
||||
* - Token already used -> 410
|
||||
* - Success -> ResetPasswordOutput with message
|
||||
*/
|
||||
final class ResetPasswordProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryPasswordResetTokenRepository $tokenRepository;
|
||||
private InMemoryRefreshTokenRepository $refreshTokenRepository;
|
||||
private Clock $clock;
|
||||
private MessageBusInterface $eventBus;
|
||||
private PasswordHasher $passwordHasher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-15 10:00:00');
|
||||
}
|
||||
};
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->tokenRepository = new InMemoryPasswordResetTokenRepository($this->clock);
|
||||
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
|
||||
$this->eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->passwordHasher = new class implements PasswordHasher {
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return '$argon2id$hashed_new';
|
||||
}
|
||||
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSuccessOnValidReset(): void
|
||||
{
|
||||
$this->saveUser();
|
||||
$token = $this->saveValidToken();
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new ResetPasswordInput();
|
||||
$input->token = $token->tokenValue;
|
||||
$input->password = 'NewSecureP@ss1';
|
||||
|
||||
$output = $processor->process($input, new Post());
|
||||
|
||||
self::assertInstanceOf(ResetPasswordOutput::class, $output);
|
||||
self::assertStringContainsString('succ', $output->message);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestWhenTokenNotFound(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new ResetPasswordInput();
|
||||
$input->token = 'nonexistent-token';
|
||||
$input->password = 'SecureP@ss1';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('invalide');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsGoneWhenTokenExpired(): void
|
||||
{
|
||||
$this->saveUser();
|
||||
|
||||
// Create a token that is already expired (created 2 hours ago, 1 hour TTL)
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-15 07:00:00'), // 3 hours ago
|
||||
);
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new ResetPasswordInput();
|
||||
$input->token = $token->tokenValue;
|
||||
$input->password = 'SecureP@ss1';
|
||||
|
||||
$this->expectException(GoneHttpException::class);
|
||||
$this->expectExceptionMessage('expir');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsGoneWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$this->saveUser();
|
||||
$token = $this->saveValidToken();
|
||||
|
||||
// Use the token once
|
||||
$this->tokenRepository->consumeIfValid($token->tokenValue, $this->clock->now());
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new ResetPasswordInput();
|
||||
$input->token = $token->tokenValue;
|
||||
$input->password = 'SecureP@ss1';
|
||||
|
||||
$this->expectException(GoneHttpException::class);
|
||||
$this->expectExceptionMessage('utilisé');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
private function createProcessor(): ResetPasswordProcessor
|
||||
{
|
||||
$handler = new ResetPasswordHandler(
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->refreshTokenRepository,
|
||||
$this->passwordHasher,
|
||||
$this->clock,
|
||||
$this->eventBus,
|
||||
);
|
||||
|
||||
return new ResetPasswordProcessor($handler);
|
||||
}
|
||||
|
||||
private function saveUser(): void
|
||||
{
|
||||
$user = User::reconstitute(
|
||||
id: \App\Administration\Domain\Model\User\UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PARENT],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: \App\Administration\Domain\Model\User\StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$old_hash',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
}
|
||||
|
||||
private function saveValidToken(): PasswordResetToken
|
||||
{
|
||||
$token = PasswordResetToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-15 09:30:00'), // 30 min ago, within 1h TTL
|
||||
);
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Port\ActiveRoleStore;
|
||||
use App\Administration\Application\Service\RoleContext;
|
||||
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\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Api\Processor\SwitchRoleProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\SwitchRoleInput;
|
||||
use App\Administration\Infrastructure\Api\Resource\SwitchRoleOutput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* Tests for SwitchRoleProcessor.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Only authenticated users can switch roles
|
||||
* - User must have the target role assigned
|
||||
* - Suspended accounts cannot switch roles
|
||||
* - Invalid role strings are rejected
|
||||
*/
|
||||
final class SwitchRoleProcessorTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
private Security $security;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private ActiveRoleStore $activeRoleStore;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->security = $this->createMock(Security::class);
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->activeRoleStore = $this->createMock(ActiveRoleStore::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function switchesRoleSuccessfully(): void
|
||||
{
|
||||
$user = $this->createMultiRoleUser();
|
||||
$this->userRepository->save($user);
|
||||
$this->authenticateAs($user);
|
||||
|
||||
$this->activeRoleStore->expects(self::once())
|
||||
->method('store')
|
||||
->with($user, Role::ADMIN);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = Role::ADMIN->value;
|
||||
|
||||
$output = $processor->process($input, new Post());
|
||||
|
||||
self::assertInstanceOf(SwitchRoleOutput::class, $output);
|
||||
self::assertSame(Role::ADMIN->value, $output->activeRole);
|
||||
self::assertSame('Directeur', $output->activeRoleLabel);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNotAuthenticated(): void
|
||||
{
|
||||
$this->security->method('getUser')->willReturn(null);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = Role::PROF->value;
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$this->expectExceptionMessage('Authentification requise');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestWhenRoleIsInvalid(): void
|
||||
{
|
||||
$user = $this->createMultiRoleUser();
|
||||
$this->userRepository->save($user);
|
||||
$this->authenticateAs($user);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = 'ROLE_NONEXISTENT';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('invalide');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestWhenRoleIsNull(): void
|
||||
{
|
||||
$user = $this->createMultiRoleUser();
|
||||
$this->userRepository->save($user);
|
||||
$this->authenticateAs($user);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = null;
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('invalide');
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsNotFoundWhenUserDoesNotExist(): void
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString(self::USER_ID),
|
||||
email: 'user@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::PROF->value, Role::ADMIN->value],
|
||||
);
|
||||
$this->security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
// User NOT saved to repository
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = Role::ADMIN->value;
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestWhenRoleNotAssigned(): void
|
||||
{
|
||||
// User only has PROF role
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PROF],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
$this->authenticateAs($user);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = Role::ADMIN->value;
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenAccountSuspended(): void
|
||||
{
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PROF, Role::ADMIN],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::SUSPENDU,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
blockedAt: new DateTimeImmutable('2026-02-10T10:00:00+00:00'),
|
||||
blockedReason: 'Reason',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
$this->authenticateAs($user);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$input = new SwitchRoleInput();
|
||||
$input->role = Role::ADMIN->value;
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($input, new Post());
|
||||
}
|
||||
|
||||
private function createProcessor(): SwitchRoleProcessor
|
||||
{
|
||||
$roleContext = new RoleContext($this->activeRoleStore);
|
||||
|
||||
return new SwitchRoleProcessor(
|
||||
$this->security,
|
||||
$this->userRepository,
|
||||
$roleContext,
|
||||
);
|
||||
}
|
||||
|
||||
private function createMultiRoleUser(): User
|
||||
{
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('user@example.com'),
|
||||
roles: [Role::PROF, Role::ADMIN],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
);
|
||||
}
|
||||
|
||||
private function authenticateAs(User $user): void
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: $user->id,
|
||||
email: (string) $user->email,
|
||||
hashedPassword: $user->hashedPassword ?? '',
|
||||
tenantId: $user->tenantId,
|
||||
roles: array_values(array_map(
|
||||
static fn (Role $r) => $r->value,
|
||||
$user->roles,
|
||||
)),
|
||||
);
|
||||
$this->security->method('getUser')->willReturn($securityUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesHandler;
|
||||
use App\Administration\Application\Port\ActiveRoleStore;
|
||||
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\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateUserRolesProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\UserVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Tests for UpdateUserRolesProcessor.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Only ADMIN/SUPER_ADMIN can manage roles
|
||||
* - Non-SUPER_ADMIN cannot assign SUPER_ADMIN
|
||||
* - Admin cannot self-demote
|
||||
* - Domain events are dispatched after successful update
|
||||
* - Tenant context must be set
|
||||
*/
|
||||
final class UpdateUserRolesProcessorTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string ADMIN_USER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private MessageBusInterface $eventBus;
|
||||
private AuthorizationCheckerInterface $authChecker;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
private Security $security;
|
||||
private ActiveRoleStore $activeRoleStore;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->authChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-15 10:00:00');
|
||||
}
|
||||
};
|
||||
$this->security = $this->createMock(Security::class);
|
||||
$this->activeRoleStore = new class implements ActiveRoleStore {
|
||||
public function store(User $user, Role $role): void
|
||||
{
|
||||
}
|
||||
|
||||
public function get(User $user): ?Role
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function clear(User $user): void
|
||||
{
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updatesRolesSuccessfully(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsAdmin();
|
||||
$this->saveTargetUser([Role::PROF]);
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::PROF->value, Role::ADMIN->value];
|
||||
|
||||
$output = $processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
|
||||
self::assertInstanceOf(UserResource::class, $output);
|
||||
self::assertSame(self::USER_ID, $output->id);
|
||||
self::assertContains(Role::ADMIN->value, $output->roles);
|
||||
self::assertContains(Role::PROF->value, $output->roles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenNotAuthorized(): void
|
||||
{
|
||||
$this->authChecker->method('isGranted')
|
||||
->with(UserVoter::MANAGE_ROLES)
|
||||
->willReturn(false);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::PROF->value];
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('autoris');
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNoTenant(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
// tenantContext NOT set
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::PROF->value];
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$this->expectExceptionMessage('Tenant');
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preventsPrivilegeEscalationToSuperAdmin(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsAdmin();
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::SUPER_ADMIN->value];
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('super administrateur');
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowsSuperAdminToAssignSuperAdmin(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsSuperAdmin();
|
||||
$this->saveTargetUser([Role::PROF]);
|
||||
|
||||
$this->eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $msg) => new Envelope($msg));
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::SUPER_ADMIN->value];
|
||||
|
||||
$output = $processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
|
||||
self::assertInstanceOf(UserResource::class, $output);
|
||||
self::assertContains(Role::SUPER_ADMIN->value, $output->roles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preventsAdminSelfDemotion(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsAdmin();
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::PROF->value];
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('propre r');
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::ADMIN_USER_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsNotFoundWhenUserDoesNotExist(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsAdmin();
|
||||
// Target user NOT saved
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = [Role::PROF->value];
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestOnInvalidRole(): void
|
||||
{
|
||||
$this->authorizeManageRoles();
|
||||
$this->setTenant();
|
||||
$this->authenticateAsAdmin();
|
||||
$this->saveTargetUser([Role::PROF]);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->roles = ['ROLE_INVALID'];
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
|
||||
$processor->process($data, new Put(), ['id' => self::USER_ID]);
|
||||
}
|
||||
|
||||
private function createProcessor(): UpdateUserRolesProcessor
|
||||
{
|
||||
$handler = new UpdateUserRolesHandler(
|
||||
$this->userRepository,
|
||||
$this->clock,
|
||||
$this->activeRoleStore,
|
||||
);
|
||||
|
||||
return new UpdateUserRolesProcessor(
|
||||
$handler,
|
||||
$this->eventBus,
|
||||
$this->authChecker,
|
||||
$this->tenantContext,
|
||||
$this->clock,
|
||||
$this->security,
|
||||
);
|
||||
}
|
||||
|
||||
private function authorizeManageRoles(): void
|
||||
{
|
||||
$this->authChecker->method('isGranted')
|
||||
->with(UserVoter::MANAGE_ROLES)
|
||||
->willReturn(true);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(
|
||||
new TenantConfig(
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
'ecole-alpha',
|
||||
'sqlite:///:memory:',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private function authenticateAsAdmin(): void
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString(self::ADMIN_USER_ID),
|
||||
email: 'admin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::ADMIN->value],
|
||||
);
|
||||
$this->security->method('getUser')->willReturn($securityUser);
|
||||
}
|
||||
|
||||
private function authenticateAsSuperAdmin(): void
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString(self::ADMIN_USER_ID),
|
||||
email: 'superadmin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::SUPER_ADMIN->value, Role::ADMIN->value],
|
||||
);
|
||||
$this->security->method('getUser')->willReturn($securityUser);
|
||||
}
|
||||
|
||||
private function saveTargetUser(array $roles): void
|
||||
{
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString(self::USER_ID),
|
||||
email: new Email('target@example.com'),
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'Ecole Test',
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
activatedAt: new DateTimeImmutable('2026-01-16T10:00:00+00:00'),
|
||||
consentementParental: null,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
);
|
||||
$this->userRepository->save($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Tests for SendActivationConfirmationHandler.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Sends email to the activated user's address
|
||||
* - Uses the correct Twig template
|
||||
* - Login URL is constructed from appUrl
|
||||
* - Role label is resolved from Role enum
|
||||
*/
|
||||
final class SendActivationConfirmationHandlerTest extends TestCase
|
||||
{
|
||||
private const string APP_URL = 'https://ecole-alpha.classeo.fr';
|
||||
private const string FROM_EMAIL = 'noreply@classeo.fr';
|
||||
|
||||
private MailerInterface $mailer;
|
||||
private Environment $twig;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mailer = $this->createMock(MailerInterface::class);
|
||||
$this->twig = $this->createMock(Environment::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sendsEmailToActivatedUser(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com', Role::PROF->value);
|
||||
|
||||
$this->twig->method('render')->willReturn('<p>Votre compte est actif.</p>');
|
||||
|
||||
$sentEmail = null;
|
||||
$this->mailer->expects(self::once())
|
||||
->method('send')
|
||||
->with(self::callback(static function (Email $email) use (&$sentEmail): bool {
|
||||
$sentEmail = $email;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
$handler = new SendActivationConfirmationHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
|
||||
self::assertNotNull($sentEmail);
|
||||
self::assertSame('user@example.com', $sentEmail->getTo()[0]->getAddress());
|
||||
self::assertSame(self::FROM_EMAIL, $sentEmail->getFrom()[0]->getAddress());
|
||||
self::assertSame('Votre compte Classeo est activé', $sentEmail->getSubject());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rendersTemplateWithCorrectVariables(): void
|
||||
{
|
||||
$event = $this->createEvent('teacher@example.com', Role::PROF->value);
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
'emails/activation_confirmation.html.twig',
|
||||
self::callback(static function (array $vars): bool {
|
||||
return $vars['email'] === 'teacher@example.com'
|
||||
&& $vars['role'] === 'Enseignant'
|
||||
&& $vars['loginUrl'] === 'https://ecole-alpha.classeo.fr/login';
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendActivationConfirmationHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handlesTrailingSlashInAppUrl(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com', Role::PARENT->value);
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $vars): bool {
|
||||
// Should not have double slash
|
||||
return $vars['loginUrl'] === 'https://ecole-alpha.classeo.fr/login';
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendActivationConfirmationHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
'https://ecole-alpha.classeo.fr/',
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function usesRoleLabelForKnownRoles(): void
|
||||
{
|
||||
$event = $this->createEvent('admin@example.com', Role::ADMIN->value);
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $vars): bool {
|
||||
return $vars['role'] === 'Directeur';
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendActivationConfirmationHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fallsBackToRawStringForUnknownRole(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com', 'ROLE_CUSTOM');
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $vars): bool {
|
||||
return $vars['role'] === 'ROLE_CUSTOM';
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendActivationConfirmationHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
private function createEvent(string $email, string $role): CompteActive
|
||||
{
|
||||
return new CompteActive(
|
||||
userId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
email: $email,
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
role: $role,
|
||||
occurredOn: new DateTimeImmutable('2026-02-15 10:00:00'),
|
||||
aggregateId: Uuid::uuid4(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\PasswordResetTokenGenerated;
|
||||
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
|
||||
use App\Administration\Infrastructure\Messaging\SendPasswordResetEmailHandler;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Tests for SendPasswordResetEmailHandler.
|
||||
*
|
||||
* Key invariants:
|
||||
* - Sends email to the user's address with a reset link
|
||||
* - Reset URL is constructed from appUrl + token value
|
||||
* - Uses the correct Twig template
|
||||
*/
|
||||
final class SendPasswordResetEmailHandlerTest extends TestCase
|
||||
{
|
||||
private const string APP_URL = 'https://ecole-alpha.classeo.fr';
|
||||
private const string FROM_EMAIL = 'noreply@classeo.fr';
|
||||
private const string TOKEN_VALUE = 'abc123def456';
|
||||
|
||||
private MailerInterface $mailer;
|
||||
private Environment $twig;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mailer = $this->createMock(MailerInterface::class);
|
||||
$this->twig = $this->createMock(Environment::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sendsResetEmailToUser(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com');
|
||||
|
||||
$this->twig->method('render')->willReturn('<p>Reset your password</p>');
|
||||
|
||||
$sentEmail = null;
|
||||
$this->mailer->expects(self::once())
|
||||
->method('send')
|
||||
->with(self::callback(static function (Email $email) use (&$sentEmail): bool {
|
||||
$sentEmail = $email;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
$handler = new SendPasswordResetEmailHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
|
||||
self::assertNotNull($sentEmail);
|
||||
self::assertSame('user@example.com', $sentEmail->getTo()[0]->getAddress());
|
||||
self::assertSame(self::FROM_EMAIL, $sentEmail->getFrom()[0]->getAddress());
|
||||
self::assertStringContainsString('initialisation', $sentEmail->getSubject());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rendersTemplateWithResetUrl(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com');
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
'emails/password_reset.html.twig',
|
||||
self::callback(static function (array $vars): bool {
|
||||
return $vars['email'] === 'user@example.com'
|
||||
&& $vars['resetUrl'] === 'https://ecole-alpha.classeo.fr/reset-password/' . self::TOKEN_VALUE;
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendPasswordResetEmailHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handlesTrailingSlashInAppUrl(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com');
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $vars): bool {
|
||||
// Should not have double slash
|
||||
return str_contains($vars['resetUrl'], '.fr/reset-password/')
|
||||
&& !str_contains($vars['resetUrl'], '.fr//');
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendPasswordResetEmailHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
'https://ecole-alpha.classeo.fr/',
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function includesTokenValueInResetUrl(): void
|
||||
{
|
||||
$customToken = 'custom-token-value-xyz';
|
||||
$event = new PasswordResetTokenGenerated(
|
||||
tokenId: PasswordResetTokenId::generate(),
|
||||
tokenValue: $customToken,
|
||||
userId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
occurredOn: new DateTimeImmutable('2026-02-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->twig->expects(self::once())
|
||||
->method('render')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $vars) use ($customToken): bool {
|
||||
return str_ends_with($vars['resetUrl'], '/reset-password/' . $customToken);
|
||||
}),
|
||||
)
|
||||
->willReturn('<p>HTML</p>');
|
||||
|
||||
$this->mailer->method('send');
|
||||
|
||||
$handler = new SendPasswordResetEmailHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function usesDefaultFromEmail(): void
|
||||
{
|
||||
$event = $this->createEvent('user@example.com');
|
||||
|
||||
$this->twig->method('render')->willReturn('<p>HTML</p>');
|
||||
|
||||
$sentEmail = null;
|
||||
$this->mailer->expects(self::once())
|
||||
->method('send')
|
||||
->with(self::callback(static function (Email $email) use (&$sentEmail): bool {
|
||||
$sentEmail = $email;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Use default fromEmail (constructor default)
|
||||
$handler = new SendPasswordResetEmailHandler(
|
||||
$this->mailer,
|
||||
$this->twig,
|
||||
self::APP_URL,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
|
||||
self::assertSame('noreply@classeo.fr', $sentEmail->getFrom()[0]->getAddress());
|
||||
}
|
||||
|
||||
private function createEvent(string $email): PasswordResetTokenGenerated
|
||||
{
|
||||
return new PasswordResetTokenGenerated(
|
||||
tokenId: PasswordResetTokenId::generate(),
|
||||
tokenValue: self::TOKEN_VALUE,
|
||||
userId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
email: $email,
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
occurredOn: new DateTimeImmutable('2026-02-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user