diff --git a/backend/tests/Unit/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodHandlerTest.php new file mode 100644 index 0000000..4804d63 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodHandlerTest.php @@ -0,0 +1,96 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php new file mode 100644 index 0000000..e6cfa9d --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessorTest.php new file mode 100644 index 0000000..afb44e2 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessorTest.php @@ -0,0 +1,315 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ResetPasswordProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ResetPasswordProcessorTest.php new file mode 100644 index 0000000..675bfdd --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ResetPasswordProcessorTest.php @@ -0,0 +1,208 @@ + 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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/SwitchRoleProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/SwitchRoleProcessorTest.php new file mode 100644 index 0000000..a5fb242 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/SwitchRoleProcessorTest.php @@ -0,0 +1,263 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessorTest.php new file mode 100644 index 0000000..07cf44c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessorTest.php @@ -0,0 +1,321 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Messaging/SendActivationConfirmationHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendActivationConfirmationHandlerTest.php new file mode 100644 index 0000000..e1e8754 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendActivationConfirmationHandlerTest.php @@ -0,0 +1,195 @@ +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('
Votre compte est actif.
'); + + $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('HTML
'); + + $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('HTML
'); + + $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('HTML
'); + + $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('HTML
'); + + $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(), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandlerTest.php new file mode 100644 index 0000000..8ab6da7 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandlerTest.php @@ -0,0 +1,203 @@ +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('Reset your password
'); + + $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('HTML
'); + + $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('HTML
'); + + $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('HTML
'); + + $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('HTML
'); + + $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'), + ); + } +} diff --git a/frontend/e2e/token-edge-cases.spec.ts b/frontend/e2e/token-edge-cases.spec.ts new file mode 100644 index 0000000..e38ba6c --- /dev/null +++ b/frontend/e2e/token-edge-cases.spec.ts @@ -0,0 +1,201 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Helper to create a password reset token via CLI command. + */ +function createResetToken(options: { email: string; expired?: boolean }): string | null { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + try { + const expiredFlag = options.expired ? ' --expired' : ''; + const result = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-password-reset-token --email=${options.email}${expiredFlag} 2>&1`, + { encoding: 'utf-8' } + ); + + const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i); + if (tokenMatch) { + return tokenMatch[1]; + } + console.error('Could not extract reset token from output:', result); + return null; + } catch (error) { + console.error('Failed to create reset token:', error); + return null; + } +} + +/** + * Helper to create an activation token via CLI command. + */ +function createActivationToken(options: { email: string; tenant?: string }): string | null { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + const tenant = options.tenant ?? 'ecole-alpha'; + + try { + const result = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=${options.email} --tenant=${tenant} 2>&1`, + { encoding: 'utf-8' } + ); + + const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i); + if (tokenMatch) { + return tokenMatch[1]; + } + console.error('Could not extract activation token from output:', result); + return null; + } catch (error) { + console.error('Failed to create activation token:', error); + return null; + } +} + +test.describe('Expired/Invalid Token Scenarios [P0]', () => { + // ============================================================================ + // Activation Token Edge Cases + // ============================================================================ + test.describe('Activation Token Validation', () => { + test('[P0] invalid activation token format shows error', async ({ page }) => { + // Use a clearly invalid token format (not a UUID) + await page.goto('/activate/invalid-token-not-a-uuid'); + + // Should show an error indicating the link is invalid + await expect( + page.getByRole('heading', { name: /lien invalide/i }) + ).toBeVisible({ timeout: 10000 }); + await expect( + page.getByText(/contacter votre établissement/i) + ).toBeVisible(); + }); + + test('[P0] non-existent activation token shows error', async ({ page }) => { + // Use a valid UUID format but a token that does not exist + await page.goto('/activate/00000000-0000-0000-0000-000000000000'); + + // Should show error because token doesn't exist in the database + await expect( + page.getByRole('heading', { name: /lien invalide/i }) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P0] reusing an already-used activation token shows error', async ({ page }, testInfo) => { + const email = `e2e-reuse-act-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createActivationToken({ email }); + test.skip(!token, 'Could not create test activation token'); + + // First activation: use the token + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password (must include special char for 5/5 requirements) + await page.locator('#password').fill('SecurePass123!'); + await page.locator('#passwordConfirmation').fill('SecurePass123!'); + + const submitButton = page.getByRole('button', { name: /activer mon compte/i }); + await expect(submitButton).toBeEnabled({ timeout: 2000 }); + + // Submit the activation + await submitButton.click(); + + // Wait for successful activation (redirects to login with query param) + await expect(page).toHaveURL(/\/login\?activated=true/, { timeout: 10000 }); + + // Second attempt: try to reuse the same token + await page.goto(`/activate/${token}`); + + // Should show an error because the token has already been consumed + await expect( + page.getByRole('heading', { name: /lien invalide/i }) + ).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // Password Reset Token Edge Cases + // ============================================================================ + test.describe('Password Reset Token Validation', () => { + test('[P0] invalid password reset token shows error after submission', async ({ page }) => { + // Use a valid UUID format but a token that does not exist + await page.goto('/reset-password/00000000-0000-0000-0000-000000000000'); + + // The form is shown initially (validation happens on submit) + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password and submit + await page.locator('#password').fill('ValidPassword123!'); + await page.locator('#confirmPassword').fill('ValidPassword123!'); + + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show error after submission + await expect( + page.getByRole('heading', { name: 'Lien invalide' }) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P0] expired password reset token shows error after submission', async ({ page }, testInfo) => { + const email = `e2e-exp-reset-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email, expired: true }); + test.skip(!token, 'Could not create expired test token'); + + await page.goto(`/reset-password/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password and submit + await page.locator('#password').fill('ValidPassword123!'); + await page.locator('#confirmPassword').fill('ValidPassword123!'); + + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show expired/invalid error + await expect( + page.getByRole('heading', { name: 'Lien invalide' }) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P0] reusing a password reset token shows error', async ({ page }, testInfo) => { + const email = `e2e-reuse-reset-${testInfo.project.name}-${Date.now()}@example.com`; + const token = createResetToken({ email }); + test.skip(!token, 'Could not create test token'); + + // First reset: use the token successfully + await page.goto(`/reset-password/${token}`); + await expect(page.locator('form')).toBeVisible({ timeout: 5000 }); + + await page.locator('#password').fill('FirstPassword123!'); + await page.locator('#confirmPassword').fill('FirstPassword123!'); + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Wait for success + await expect( + page.getByRole('heading', { name: 'Mot de passe modifié' }) + ).toBeVisible({ timeout: 10000 }); + + // Second attempt: try to reuse the same token + await page.goto(`/reset-password/${token}`); + await expect(page.locator('form')).toBeVisible({ timeout: 5000 }); + + await page.locator('#password').fill('SecondPassword123!'); + await page.locator('#confirmPassword').fill('SecondPassword123!'); + await page.getByRole('button', { name: /réinitialiser/i }).click(); + + // Should show error (token already used) + await expect( + page.getByRole('heading', { name: 'Lien invalide' }) + ).toBeVisible({ timeout: 10000 }); + }); + }); +}); diff --git a/frontend/tests/unit/lib/api/config.test.ts b/frontend/tests/unit/lib/api/config.test.ts new file mode 100644 index 0000000..8d4910b --- /dev/null +++ b/frontend/tests/unit/lib/api/config.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the API config module (config.ts). + * + * Tests getApiBaseUrl() and getCurrentTenant() which depend on: + * - $app/environment (browser flag) + * - $env/dynamic/public (environment variables) + * - window.location (hostname, protocol) + */ + +// Mutable env object so we can change values per-test +const mockEnv: Record