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 = {}; + +// Mock $app/environment - default to browser=true +let mockBrowser = true; +vi.mock('$app/environment', () => ({ + get browser() { + return mockBrowser; + } +})); + +// Mock $env/dynamic/public +vi.mock('$env/dynamic/public', () => ({ + env: new Proxy(mockEnv, { + get(target, prop: string) { + return target[prop]; + } + }) +})); + +describe('API config', () => { + let configModule: typeof import('$lib/api/config'); + + beforeEach(async () => { + // Reset env between tests + Object.keys(mockEnv).forEach((key) => delete mockEnv[key]); + mockBrowser = true; + + // Reset modules to get a fresh import + vi.resetModules(); + configModule = await import('$lib/api/config'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // getApiBaseUrl + // ========================================================================== + describe('getApiBaseUrl', () => { + it('should return protocol://hostname:port/api when in browser with PUBLIC_API_PORT', () => { + mockBrowser = true; + mockEnv['PUBLIC_API_PORT'] = '18000'; + + // jsdom provides window.location - set it for the test + // Default jsdom location is http://localhost, so we work with that + const result = configModule.getApiBaseUrl(); + + expect(result).toBe(`${window.location.protocol}//${window.location.hostname}:18000/api`); + }); + + it('should return /api when in browser without PUBLIC_API_PORT', () => { + mockBrowser = true; + // PUBLIC_API_PORT is not set (undefined) + + const result = configModule.getApiBaseUrl(); + + expect(result).toBe('/api'); + }); + + it('should return PUBLIC_API_URL when in SSR and PUBLIC_API_URL is set', () => { + mockBrowser = false; + mockEnv['PUBLIC_API_URL'] = 'https://api.classeo.fr/api'; + + const result = configModule.getApiBaseUrl(); + + expect(result).toBe('https://api.classeo.fr/api'); + }); + + it('should return http://php:8000/api as SSR fallback', () => { + mockBrowser = false; + // PUBLIC_API_URL is not set + + const result = configModule.getApiBaseUrl(); + + expect(result).toBe('http://php:8000/api'); + }); + }); + + // ========================================================================== + // getCurrentTenant + // ========================================================================== + describe('getCurrentTenant', () => { + it('should return null when not in browser', () => { + mockBrowser = false; + + const result = configModule.getCurrentTenant(); + + expect(result).toBeNull(); + }); + + it('should extract subdomain correctly from hostname', () => { + mockBrowser = true; + mockEnv['PUBLIC_BASE_DOMAIN'] = 'classeo.local'; + + // Override window.location.hostname for this test + // jsdom default is "localhost" which won't match "classeo.local" + // We need to use Object.defineProperty since location is readonly + const originalHostname = window.location.hostname; + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: 'ecole-alpha.classeo.local' }, + writable: true + }); + + const result = configModule.getCurrentTenant(); + + expect(result).toBe('ecole-alpha'); + + // Restore + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: originalHostname }, + writable: true + }); + }); + + it('should return null for www subdomain', () => { + mockBrowser = true; + mockEnv['PUBLIC_BASE_DOMAIN'] = 'classeo.fr'; + + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: 'www.classeo.fr' }, + writable: true + }); + + const result = configModule.getCurrentTenant(); + + expect(result).toBeNull(); + + // Restore + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: 'localhost' }, + writable: true + }); + }); + + it('should return null when hostname does not match base domain', () => { + mockBrowser = true; + mockEnv['PUBLIC_BASE_DOMAIN'] = 'classeo.fr'; + + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: 'other-domain.com' }, + writable: true + }); + + const result = configModule.getCurrentTenant(); + + expect(result).toBeNull(); + + // Restore + Object.defineProperty(window, 'location', { + value: { ...window.location, hostname: 'localhost' }, + writable: true + }); + }); + }); +}); diff --git a/frontend/tests/unit/lib/constants/schoolLevels.test.ts b/frontend/tests/unit/lib/constants/schoolLevels.test.ts new file mode 100644 index 0000000..31acab2 --- /dev/null +++ b/frontend/tests/unit/lib/constants/schoolLevels.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { SCHOOL_LEVELS, SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels'; +import type { SchoolLevel } from '$lib/constants/schoolLevels'; + +/** + * Unit tests for the school levels constants (schoolLevels.ts). + * + * Verifies the complete list of levels matches the Education Nationale + * reference, the correct ordering from CP to Terminale, and the + * SCHOOL_LEVEL_OPTIONS formatting for select/dropdown components. + */ + +describe('schoolLevels constants', () => { + // ========================================================================== + // SCHOOL_LEVELS + // ========================================================================== + describe('SCHOOL_LEVELS', () => { + it('should contain exactly 12 levels', () => { + expect(SCHOOL_LEVELS).toHaveLength(12); + }); + + it('should contain all primary school levels (CP to CM2)', () => { + expect(SCHOOL_LEVELS).toContain('CP'); + expect(SCHOOL_LEVELS).toContain('CE1'); + expect(SCHOOL_LEVELS).toContain('CE2'); + expect(SCHOOL_LEVELS).toContain('CM1'); + expect(SCHOOL_LEVELS).toContain('CM2'); + }); + + it('should contain all college levels (6eme to 3eme)', () => { + expect(SCHOOL_LEVELS).toContain('6ème'); + expect(SCHOOL_LEVELS).toContain('5ème'); + expect(SCHOOL_LEVELS).toContain('4ème'); + expect(SCHOOL_LEVELS).toContain('3ème'); + }); + + it('should contain all lycee levels (2nde, 1ere, Terminale)', () => { + expect(SCHOOL_LEVELS).toContain('2nde'); + expect(SCHOOL_LEVELS).toContain('1ère'); + expect(SCHOOL_LEVELS).toContain('Terminale'); + }); + + it('should be ordered from CP to Terminale', () => { + const expectedOrder = [ + 'CP', + 'CE1', + 'CE2', + 'CM1', + 'CM2', + '6ème', + '5ème', + '4ème', + '3ème', + '2nde', + '1ère', + 'Terminale' + ]; + expect([...SCHOOL_LEVELS]).toEqual(expectedOrder); + }); + + it('should be a readonly array (as const)', () => { + // TypeScript ensures this at compile time, but we verify the runtime + // values are stable by checking identity + const firstRead = SCHOOL_LEVELS[0]; + const secondRead = SCHOOL_LEVELS[0]; + expect(firstRead).toBe(secondRead); + expect(firstRead).toBe('CP'); + }); + }); + + // ========================================================================== + // SchoolLevel type (compile-time check via runtime usage) + // ========================================================================== + describe('SchoolLevel type', () => { + it('should accept valid school level values', () => { + // This is a compile-time check expressed as a runtime test + const level: SchoolLevel = 'CP'; + expect(level).toBe('CP'); + + const another: SchoolLevel = 'Terminale'; + expect(another).toBe('Terminale'); + }); + }); + + // ========================================================================== + // SCHOOL_LEVEL_OPTIONS + // ========================================================================== + describe('SCHOOL_LEVEL_OPTIONS', () => { + it('should have the same number of options as SCHOOL_LEVELS', () => { + expect(SCHOOL_LEVEL_OPTIONS).toHaveLength(SCHOOL_LEVELS.length); + }); + + it('should have value and label properties for each option', () => { + for (const option of SCHOOL_LEVEL_OPTIONS) { + expect(option).toHaveProperty('value'); + expect(option).toHaveProperty('label'); + } + }); + + it('should use the level as both value and label', () => { + for (let i = 0; i < SCHOOL_LEVELS.length; i++) { + expect(SCHOOL_LEVEL_OPTIONS[i]!.value).toBe(SCHOOL_LEVELS[i]); + expect(SCHOOL_LEVEL_OPTIONS[i]!.label).toBe(SCHOOL_LEVELS[i]); + } + }); + + it('should format first option correctly (CP)', () => { + expect(SCHOOL_LEVEL_OPTIONS[0]).toEqual({ value: 'CP', label: 'CP' }); + }); + + it('should format last option correctly (Terminale)', () => { + const last = SCHOOL_LEVEL_OPTIONS[SCHOOL_LEVEL_OPTIONS.length - 1]; + expect(last).toEqual({ value: 'Terminale', label: 'Terminale' }); + }); + + it('should format accented levels correctly', () => { + const sixieme = SCHOOL_LEVEL_OPTIONS.find((opt) => opt.value === '6ème'); + expect(sixieme).toEqual({ value: '6ème', label: '6ème' }); + + const premiere = SCHOOL_LEVEL_OPTIONS.find((opt) => opt.value === '1ère'); + expect(premiere).toEqual({ value: '1ère', label: '1ère' }); + }); + + it('should preserve the order from SCHOOL_LEVELS', () => { + const optionValues = SCHOOL_LEVEL_OPTIONS.map((opt) => opt.value); + expect(optionValues).toEqual([...SCHOOL_LEVELS]); + }); + }); +}); diff --git a/frontend/tests/unit/lib/features/roles/api/roles.test.ts b/frontend/tests/unit/lib/features/roles/api/roles.test.ts new file mode 100644 index 0000000..5237537 --- /dev/null +++ b/frontend/tests/unit/lib/features/roles/api/roles.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the roles API module. + * + * Tests getMyRoles(), switchRole(), and updateUserRoles() which all rely on + * authenticatedFetch from $lib/auth and getApiBaseUrl from $lib/api. + */ + +// Mock $lib/api +vi.mock('$lib/api', () => ({ + getApiBaseUrl: () => 'http://test.classeo.local:18000/api' +})); + +// Mock $lib/auth - authenticatedFetch is the primary dependency +const mockAuthenticatedFetch = vi.fn(); +vi.mock('$lib/auth', () => ({ + authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args) +})); + +import { getMyRoles, switchRole, updateUserRoles } from '$lib/features/roles/api/roles'; + +describe('roles API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // getMyRoles + // ========================================================================== + describe('getMyRoles', () => { + it('should return roles array and activeRole on success', async () => { + const mockResponse = { + roles: [ + { value: 'ROLE_ADMIN', label: 'Administrateur' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Administrateur' + }; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse) + }); + + const result = await getMyRoles(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/me/roles' + ); + expect(result.roles).toHaveLength(2); + expect(result.roles[0]).toEqual({ value: 'ROLE_ADMIN', label: 'Administrateur' }); + expect(result.activeRole).toBe('ROLE_ADMIN'); + expect(result.activeRoleLabel).toBe('Administrateur'); + }); + + it('should throw Error when the API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(getMyRoles()).rejects.toThrow('Failed to fetch roles'); + }); + }); + + // ========================================================================== + // switchRole + // ========================================================================== + describe('switchRole', () => { + it('should return new activeRole on success', async () => { + const mockResponse = { + activeRole: 'ROLE_TEACHER', + activeRoleLabel: 'Enseignant' + }; + + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse) + }); + + const result = await switchRole('ROLE_TEACHER'); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/me/switch-role', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'ROLE_TEACHER' }) + }) + ); + expect(result.activeRole).toBe('ROLE_TEACHER'); + expect(result.activeRoleLabel).toBe('Enseignant'); + }); + + it('should throw Error when the API response is not ok', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 400 + }); + + await expect(switchRole('ROLE_INVALID')).rejects.toThrow('Failed to switch role'); + }); + }); + + // ========================================================================== + // updateUserRoles + // ========================================================================== + describe('updateUserRoles', () => { + it('should complete without error on 2xx success', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: true, + status: 204 + }); + + // Should resolve without throwing + await expect( + updateUserRoles('user-uuid-123', ['ROLE_ADMIN', 'ROLE_TEACHER']) + ).resolves.toBeUndefined(); + + expect(mockAuthenticatedFetch).toHaveBeenCalledWith( + 'http://test.classeo.local:18000/api/users/user-uuid-123/roles', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roles: ['ROLE_ADMIN', 'ROLE_TEACHER'] }) + }) + ); + }); + + it('should throw with hydra:description when present in error response', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => + Promise.resolve({ + 'hydra:description': 'Le rôle ROLE_INVALID est inconnu.' + }) + }); + + await expect( + updateUserRoles('user-uuid-123', ['ROLE_INVALID']) + ).rejects.toThrow('Le rôle ROLE_INVALID est inconnu.'); + }); + + it('should throw with detail when present in error response', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + detail: 'Accès refusé.' + }) + }); + + await expect( + updateUserRoles('user-uuid-123', ['ROLE_ADMIN']) + ).rejects.toThrow('Accès refusé.'); + }); + + it('should throw generic message when error body is not valid JSON', async () => { + mockAuthenticatedFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Unexpected token')) + }); + + await expect( + updateUserRoles('user-uuid-123', ['ROLE_ADMIN']) + ).rejects.toThrow('Erreur lors de la mise à jour des rôles (500)'); + }); + }); +}); diff --git a/frontend/tests/unit/lib/features/roles/roleContext.test.ts b/frontend/tests/unit/lib/features/roles/roleContext.test.ts new file mode 100644 index 0000000..ccc9f81 --- /dev/null +++ b/frontend/tests/unit/lib/features/roles/roleContext.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the role context (roleContext.svelte.ts). + * + * This module uses Svelte 5 $state runes for reactive state management. + * We test it through its public exported API and mock the underlying + * roles API module to isolate the context logic. + */ + +// Mock the roles API module +const mockGetMyRoles = vi.fn(); +const mockSwitchRole = vi.fn(); +vi.mock('$lib/features/roles/api/roles', () => ({ + getMyRoles: (...args: unknown[]) => mockGetMyRoles(...args), + switchRole: (...args: unknown[]) => mockSwitchRole(...args) +})); + +describe('roleContext', () => { + let roleContext: typeof import('$lib/features/roles/roleContext.svelte'); + + beforeEach(async () => { + vi.clearAllMocks(); + + // Fresh import to reset $state between tests + vi.resetModules(); + roleContext = await import('$lib/features/roles/roleContext.svelte'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // Initial state + // ========================================================================== + describe('initial state', () => { + it('should have no roles initially', () => { + expect(roleContext.getRoles()).toEqual([]); + }); + + it('should have null activeRole initially', () => { + expect(roleContext.getActiveRole()).toBeNull(); + }); + + it('should have null activeRoleLabel initially', () => { + expect(roleContext.getActiveRoleLabel()).toBeNull(); + }); + + it('should not be loading initially', () => { + expect(roleContext.getIsLoading()).toBe(false); + }); + + it('should not be switching initially', () => { + expect(roleContext.getIsSwitching()).toBe(false); + }); + + it('should not have multiple roles initially', () => { + expect(roleContext.hasMultipleRoles()).toBe(false); + }); + }); + + // ========================================================================== + // fetchRoles + // ========================================================================== + describe('fetchRoles', () => { + it('should load roles from API and set state', async () => { + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Administrateur' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Administrateur' + }); + + await roleContext.fetchRoles(); + + expect(mockGetMyRoles).toHaveBeenCalledOnce(); + expect(roleContext.getRoles()).toHaveLength(2); + expect(roleContext.getActiveRole()).toBe('ROLE_ADMIN'); + expect(roleContext.getActiveRoleLabel()).toBe('Administrateur'); + expect(roleContext.getIsLoading()).toBe(false); + }); + + it('should guard against double loading (isFetched)', async () => { + mockGetMyRoles.mockResolvedValueOnce({ + roles: [{ value: 'ROLE_ADMIN', label: 'Admin' }], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + + // First call loads data + await roleContext.fetchRoles(); + expect(mockGetMyRoles).toHaveBeenCalledOnce(); + + // Second call should be a no-op due to isFetched guard + await roleContext.fetchRoles(); + expect(mockGetMyRoles).toHaveBeenCalledOnce(); + }); + + it('should handle API errors gracefully without throwing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetMyRoles.mockRejectedValueOnce(new Error('Network error')); + + // Should not throw + await roleContext.fetchRoles(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[roleContext] Failed to fetch roles:', + expect.any(Error) + ); + // State should remain empty + expect(roleContext.getRoles()).toEqual([]); + expect(roleContext.getActiveRole()).toBeNull(); + expect(roleContext.getIsLoading()).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + // ========================================================================== + // switchTo + // ========================================================================== + describe('switchTo', () => { + it('should return true immediately when switching to same role', async () => { + // First, load roles so activeRole is set + mockGetMyRoles.mockResolvedValueOnce({ + roles: [{ value: 'ROLE_ADMIN', label: 'Admin' }], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + // Switch to the same role + const result = await roleContext.switchTo('ROLE_ADMIN'); + + expect(result).toBe(true); + // API should NOT be called for same-role switch + expect(mockSwitchRole).not.toHaveBeenCalled(); + }); + + it('should call API and update state when switching to different role', async () => { + // Load initial roles + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Admin' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + // Switch to a different role + mockSwitchRole.mockResolvedValueOnce({ + activeRole: 'ROLE_TEACHER', + activeRoleLabel: 'Enseignant' + }); + + const result = await roleContext.switchTo('ROLE_TEACHER'); + + expect(result).toBe(true); + expect(mockSwitchRole).toHaveBeenCalledWith('ROLE_TEACHER'); + expect(roleContext.getActiveRole()).toBe('ROLE_TEACHER'); + expect(roleContext.getActiveRoleLabel()).toBe('Enseignant'); + expect(roleContext.getIsSwitching()).toBe(false); + }); + + it('should return false when the API call fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Load initial roles + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Admin' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + // Switch fails + mockSwitchRole.mockRejectedValueOnce(new Error('Server error')); + + const result = await roleContext.switchTo('ROLE_TEACHER'); + + expect(result).toBe(false); + expect(roleContext.getActiveRole()).toBe('ROLE_ADMIN'); // unchanged + expect(roleContext.getIsSwitching()).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + // ========================================================================== + // hasMultipleRoles + // ========================================================================== + describe('hasMultipleRoles', () => { + it('should return true when user has more than one role', async () => { + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Admin' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + expect(roleContext.hasMultipleRoles()).toBe(true); + }); + + it('should return false when user has zero roles', () => { + // No fetch, so roles is empty + expect(roleContext.hasMultipleRoles()).toBe(false); + }); + + it('should return false when user has exactly one role', async () => { + mockGetMyRoles.mockResolvedValueOnce({ + roles: [{ value: 'ROLE_ADMIN', label: 'Admin' }], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + expect(roleContext.hasMultipleRoles()).toBe(false); + }); + }); + + // ========================================================================== + // resetRoleContext + // ========================================================================== + describe('resetRoleContext', () => { + it('should clear all state back to initial values', async () => { + // Load some data first + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Admin' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + // Verify state is set + expect(roleContext.getRoles()).toHaveLength(2); + expect(roleContext.getActiveRole()).toBe('ROLE_ADMIN'); + + // Reset + roleContext.resetRoleContext(); + + expect(roleContext.getRoles()).toEqual([]); + expect(roleContext.getActiveRole()).toBeNull(); + expect(roleContext.getActiveRoleLabel()).toBeNull(); + expect(roleContext.hasMultipleRoles()).toBe(false); + }); + + it('should allow fetchRoles to be called again after reset', async () => { + // Load initial data + mockGetMyRoles.mockResolvedValueOnce({ + roles: [{ value: 'ROLE_ADMIN', label: 'Admin' }], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + expect(mockGetMyRoles).toHaveBeenCalledOnce(); + + // Reset clears isFetched + roleContext.resetRoleContext(); + + // Now fetchRoles should call the API again + mockGetMyRoles.mockResolvedValueOnce({ + roles: [{ value: 'ROLE_TEACHER', label: 'Enseignant' }], + activeRole: 'ROLE_TEACHER', + activeRoleLabel: 'Enseignant' + }); + await roleContext.fetchRoles(); + + expect(mockGetMyRoles).toHaveBeenCalledTimes(2); + expect(roleContext.getActiveRole()).toBe('ROLE_TEACHER'); + }); + }); + + // ========================================================================== + // getIsLoading / getIsSwitching + // ========================================================================== + describe('loading and switching state', () => { + it('should set isLoading to true during fetchRoles and false after', async () => { + // Use a deferred promise to control resolution timing + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockGetMyRoles.mockReturnValueOnce(pendingPromise); + + const fetchPromise = roleContext.fetchRoles(); + + // While the API call is pending, isLoading should be true + expect(roleContext.getIsLoading()).toBe(true); + + // Resolve the API call + resolvePromise!({ + roles: [{ value: 'ROLE_ADMIN', label: 'Admin' }], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + + await fetchPromise; + + expect(roleContext.getIsLoading()).toBe(false); + }); + + it('should set isSwitching to true during switchTo and false after', async () => { + // Load roles first so activeRole is set + mockGetMyRoles.mockResolvedValueOnce({ + roles: [ + { value: 'ROLE_ADMIN', label: 'Admin' }, + { value: 'ROLE_TEACHER', label: 'Enseignant' } + ], + activeRole: 'ROLE_ADMIN', + activeRoleLabel: 'Admin' + }); + await roleContext.fetchRoles(); + + // Use a deferred promise for switch + let resolvePromise: (value: unknown) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockSwitchRole.mockReturnValueOnce(pendingPromise); + + const switchPromise = roleContext.switchTo('ROLE_TEACHER'); + + // While switching, isSwitching should be true + expect(roleContext.getIsSwitching()).toBe(true); + + // Resolve + resolvePromise!({ + activeRole: 'ROLE_TEACHER', + activeRoleLabel: 'Enseignant' + }); + + await switchPromise; + + expect(roleContext.getIsSwitching()).toBe(false); + }); + }); +}); diff --git a/frontend/tests/unit/lib/monitoring/sentry.test.ts b/frontend/tests/unit/lib/monitoring/sentry.test.ts new file mode 100644 index 0000000..ccb4694 --- /dev/null +++ b/frontend/tests/unit/lib/monitoring/sentry.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the Sentry/GlitchTip monitoring module (sentry.ts). + * + * Tests initialization, user context management, error capture, and + * breadcrumb recording. Verifies RGPD compliance: no PII is sent to + * GlitchTip (Authorization, Cookie headers are scrubbed, emails are + * redacted from breadcrumbs). + */ + +// Mock @sentry/sveltekit before importing the module +const mockInit = vi.fn(); +const mockSetUser = vi.fn(); +const mockSetTag = vi.fn(); +const mockCaptureException = vi.fn(); +const mockAddBreadcrumb = vi.fn(); + +vi.mock('@sentry/sveltekit', () => ({ + init: mockInit, + setUser: mockSetUser, + setTag: mockSetTag, + captureException: mockCaptureException, + addBreadcrumb: mockAddBreadcrumb +})); + +describe('sentry monitoring', () => { + let sentryModule: typeof import('$lib/monitoring/sentry'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + vi.resetModules(); + sentryModule = await import('$lib/monitoring/sentry'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // initSentry + // ========================================================================== + describe('initSentry', () => { + it('should call Sentry.init with correct configuration', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + expect(mockInit).toHaveBeenCalledOnce(); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production', + sampleRate: 1.0, + tracesSampleRate: 0.0, + sendDefaultPii: false + }) + ); + }); + + it('should not initialize Sentry when DSN is empty', () => { + sentryModule.initSentry({ + dsn: '', + environment: 'test' + }); + + expect(mockInit).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + '[Sentry] DSN not configured, error tracking disabled' + ); + }); + + it('should set user context when userId is provided', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production', + userId: 'user-abc-123' + }); + + expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' }); + }); + + it('should not set user context when userId is not provided', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + expect(mockSetUser).not.toHaveBeenCalled(); + }); + + it('should set tenant tag when tenantId is provided', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'staging', + tenantId: 'ecole-alpha' + }); + + expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'ecole-alpha'); + }); + + it('should not set tenant tag when tenantId is not provided', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + expect(mockSetTag).not.toHaveBeenCalled(); + }); + + it('should set both user and tenant when both are provided', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production', + userId: 'user-xyz', + tenantId: 'lycee-beta' + }); + + expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-xyz' }); + expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'lycee-beta'); + }); + + it('should disable performance tracing (tracesSampleRate = 0)', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ + tracesSampleRate: 0.0 + }) + ); + }); + + it('should disable sendDefaultPii for RGPD compliance', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ + sendDefaultPii: false + }) + ); + }); + + it('should configure ignoreErrors for common non-errors', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const initConfig = mockInit.mock.calls[0]![0]; + expect(initConfig.ignoreErrors).toEqual( + expect.arrayContaining([ + 'ResizeObserver loop', + 'ResizeObserver loop limit exceeded', + 'NetworkError', + 'Failed to fetch', + 'Load failed', + 'AbortError' + ]) + ); + }); + + // PII scrubbing via beforeSend + describe('beforeSend PII scrubbing', () => { + it('should remove Authorization header from event request', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { + request: { + headers: { + Authorization: 'Bearer secret-token', + 'Content-Type': 'application/json' + } + } + }; + + const result = beforeSend(event); + + expect(result.request.headers.Authorization).toBeUndefined(); + expect(result.request.headers['Content-Type']).toBe('application/json'); + }); + + it('should remove Cookie header from event request', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { + request: { + headers: { + Cookie: 'session=abc123', + Accept: 'text/html' + } + } + }; + + const result = beforeSend(event); + + expect(result.request.headers.Cookie).toBeUndefined(); + expect(result.request.headers.Accept).toBe('text/html'); + }); + + it('should redact email-like strings from breadcrumb messages', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { + breadcrumbs: [ + { message: 'Login failed for user@example.com', category: 'auth' }, + { message: 'Page loaded', category: 'navigation' }, + { message: 'Error with admin@school.fr account', category: 'auth' } + ] + }; + + const result = beforeSend(event); + + expect(result.breadcrumbs[0].message).toBe('[EMAIL_REDACTED]'); + expect(result.breadcrumbs[1].message).toBe('Page loaded'); + expect(result.breadcrumbs[2].message).toBe('[EMAIL_REDACTED]'); + }); + + it('should pass through events without request or breadcrumbs', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { message: 'Simple error' }; + + const result = beforeSend(event); + + expect(result).toEqual({ message: 'Simple error' }); + }); + + it('should handle event with request but no headers', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { request: { url: 'https://example.com' } }; + + const result = beforeSend(event); + + expect(result).toEqual({ request: { url: 'https://example.com' } }); + }); + + it('should handle breadcrumbs without message', () => { + sentryModule.initSentry({ + dsn: 'https://key@glitchtip.classeo.fr/1', + environment: 'production' + }); + + const beforeSend = mockInit.mock.calls[0]![0].beforeSend; + const event = { + breadcrumbs: [ + { category: 'http', data: { url: '/api/test' } } + ] + }; + + const result = beforeSend(event); + + expect(result.breadcrumbs[0].category).toBe('http'); + }); + }); + }); + + // ========================================================================== + // setUserContext + // ========================================================================== + describe('setUserContext', () => { + it('should set user with ID only (no PII)', () => { + sentryModule.setUserContext('user-abc-123'); + + expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' }); + }); + + it('should set tenant tag when tenantId is provided', () => { + sentryModule.setUserContext('user-abc-123', 'ecole-alpha'); + + expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' }); + expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'ecole-alpha'); + }); + + it('should not set tenant tag when tenantId is not provided', () => { + sentryModule.setUserContext('user-abc-123'); + + expect(mockSetTag).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // clearUserContext + // ========================================================================== + describe('clearUserContext', () => { + it('should set user to null', () => { + sentryModule.clearUserContext(); + + expect(mockSetUser).toHaveBeenCalledWith(null); + }); + }); + + // ========================================================================== + // captureError + // ========================================================================== + describe('captureError', () => { + it('should call Sentry.captureException with the error', () => { + const error = new Error('Something went wrong'); + + sentryModule.captureError(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error, undefined); + }); + + it('should pass extra context when provided', () => { + const error = new Error('API error'); + const context = { endpoint: '/api/users', statusCode: 500 }; + + sentryModule.captureError(error, context); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + extra: { endpoint: '/api/users', statusCode: 500 } + }); + }); + + it('should handle non-Error objects', () => { + const errorString = 'string error'; + + sentryModule.captureError(errorString); + + expect(mockCaptureException).toHaveBeenCalledWith(errorString, undefined); + }); + }); + + // ========================================================================== + // addBreadcrumb + // ========================================================================== + describe('addBreadcrumb', () => { + it('should add breadcrumb with category and message', () => { + sentryModule.addBreadcrumb('navigation', 'User navigated to /dashboard'); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + message: 'User navigated to /dashboard', + level: 'info' + }); + }); + + it('should include data when provided', () => { + sentryModule.addBreadcrumb('api', 'API call', { + url: '/api/students', + method: 'GET' + }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'api', + message: 'API call', + level: 'info', + data: { url: '/api/students', method: 'GET' } + }); + }); + + it('should not include data key when data is not provided', () => { + sentryModule.addBreadcrumb('ui', 'Button clicked'); + + const call = mockAddBreadcrumb.mock.calls[0]![0]; + expect(call).not.toHaveProperty('data'); + }); + }); +}); diff --git a/frontend/tests/unit/lib/monitoring/webVitals.test.ts b/frontend/tests/unit/lib/monitoring/webVitals.test.ts new file mode 100644 index 0000000..734e251 --- /dev/null +++ b/frontend/tests/unit/lib/monitoring/webVitals.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Unit tests for the Web Vitals monitoring module (webVitals.ts). + * + * Tests metric collection initialization, rating calculations against + * Core Web Vitals 2024 thresholds, and the default reporter behavior + * (console logging in debug mode, sendBeacon/fetch in production). + */ + +// Capture the callbacks registered by initWebVitals +const mockOnLCP = vi.fn(); +const mockOnCLS = vi.fn(); +const mockOnINP = vi.fn(); +const mockOnFCP = vi.fn(); +const mockOnTTFB = vi.fn(); + +vi.mock('web-vitals', () => ({ + onLCP: mockOnLCP, + onCLS: mockOnCLS, + onINP: mockOnINP, + onFCP: mockOnFCP, + onTTFB: mockOnTTFB +})); + +describe('webVitals monitoring', () => { + let webVitalsModule: typeof import('$lib/monitoring/webVitals'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.resetModules(); + webVitalsModule = await import('$lib/monitoring/webVitals'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================== + // initWebVitals + // ========================================================================== + describe('initWebVitals', () => { + it('should register callbacks for all five web vitals', () => { + const reporter = vi.fn(); + + webVitalsModule.initWebVitals(reporter); + + expect(mockOnLCP).toHaveBeenCalledOnce(); + expect(mockOnCLS).toHaveBeenCalledOnce(); + expect(mockOnINP).toHaveBeenCalledOnce(); + expect(mockOnFCP).toHaveBeenCalledOnce(); + expect(mockOnTTFB).toHaveBeenCalledOnce(); + }); + + it('should pass metrics to the reporter when LCP fires', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + // Simulate LCP metric firing + const lcpCallback = mockOnLCP.mock.calls[0]![0]; + lcpCallback({ name: 'LCP', value: 2000, delta: 2000, id: 'v1-lcp' }); + + expect(reporter).toHaveBeenCalledOnce(); + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'LCP', + value: 2000, + rating: 'good', + delta: 2000, + id: 'v1-lcp' + }) + ); + }); + + it('should pass metrics to the reporter when CLS fires', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const clsCallback = mockOnCLS.mock.calls[0]![0]; + clsCallback({ name: 'CLS', value: 0.05, delta: 0.05, id: 'v1-cls' }); + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'CLS', + value: 0.05, + rating: 'good', + delta: 0.05, + id: 'v1-cls' + }) + ); + }); + + it('should pass metrics to the reporter when INP fires', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const inpCallback = mockOnINP.mock.calls[0]![0]; + inpCallback({ name: 'INP', value: 150, delta: 150, id: 'v1-inp' }); + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'INP', + value: 150, + rating: 'good' + }) + ); + }); + + it('should pass metrics to the reporter when FCP fires', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const fcpCallback = mockOnFCP.mock.calls[0]![0]; + fcpCallback({ name: 'FCP', value: 1500, delta: 1500, id: 'v1-fcp' }); + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'FCP', + value: 1500, + rating: 'good' + }) + ); + }); + + it('should pass metrics to the reporter when TTFB fires', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; + ttfbCallback({ name: 'TTFB', value: 600, delta: 600, id: 'v1-ttfb' }); + + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'TTFB', + value: 600, + rating: 'good' + }) + ); + }); + }); + + // ========================================================================== + // Rating calculations (via initWebVitals callback pipeline) + // ========================================================================== + describe('rating calculations', () => { + // LCP thresholds: good <= 2500, needs-improvement <= 4000, poor > 4000 + describe('LCP ratings', () => { + it('should rate LCP as good when value <= 2500', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const lcpCallback = mockOnLCP.mock.calls[0]![0]; + lcpCallback({ name: 'LCP', value: 2500, delta: 2500, id: 'lcp-1' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('good'); + }); + + it('should rate LCP as needs-improvement when 2500 < value <= 4000', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const lcpCallback = mockOnLCP.mock.calls[0]![0]; + lcpCallback({ name: 'LCP', value: 3000, delta: 3000, id: 'lcp-2' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); + }); + + it('should rate LCP as poor when value > 4000', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const lcpCallback = mockOnLCP.mock.calls[0]![0]; + lcpCallback({ name: 'LCP', value: 5000, delta: 5000, id: 'lcp-3' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('poor'); + }); + }); + + // FCP thresholds: good <= 1800, needs-improvement <= 3000, poor > 3000 + describe('FCP ratings', () => { + it('should rate FCP as good when value <= 1800', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const fcpCallback = mockOnFCP.mock.calls[0]![0]; + fcpCallback({ name: 'FCP', value: 1800, delta: 1800, id: 'fcp-1' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('good'); + }); + + it('should rate FCP as needs-improvement when 1800 < value <= 3000', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const fcpCallback = mockOnFCP.mock.calls[0]![0]; + fcpCallback({ name: 'FCP', value: 2500, delta: 2500, id: 'fcp-2' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); + }); + + it('should rate FCP as poor when value > 3000', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const fcpCallback = mockOnFCP.mock.calls[0]![0]; + fcpCallback({ name: 'FCP', value: 4000, delta: 4000, id: 'fcp-3' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('poor'); + }); + }); + + // INP thresholds: good <= 200, needs-improvement <= 500, poor > 500 + describe('INP ratings', () => { + it('should rate INP as good when value <= 200', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const inpCallback = mockOnINP.mock.calls[0]![0]; + inpCallback({ name: 'INP', value: 200, delta: 200, id: 'inp-1' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('good'); + }); + + it('should rate INP as needs-improvement when 200 < value <= 500', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const inpCallback = mockOnINP.mock.calls[0]![0]; + inpCallback({ name: 'INP', value: 350, delta: 350, id: 'inp-2' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); + }); + + it('should rate INP as poor when value > 500', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const inpCallback = mockOnINP.mock.calls[0]![0]; + inpCallback({ name: 'INP', value: 600, delta: 600, id: 'inp-3' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('poor'); + }); + }); + + // CLS thresholds: good <= 0.1, needs-improvement <= 0.25, poor > 0.25 + describe('CLS ratings', () => { + it('should rate CLS as good when value <= 0.1', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const clsCallback = mockOnCLS.mock.calls[0]![0]; + clsCallback({ name: 'CLS', value: 0.1, delta: 0.1, id: 'cls-1' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('good'); + }); + + it('should rate CLS as needs-improvement when 0.1 < value <= 0.25', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const clsCallback = mockOnCLS.mock.calls[0]![0]; + clsCallback({ name: 'CLS', value: 0.15, delta: 0.15, id: 'cls-2' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); + }); + + it('should rate CLS as poor when value > 0.25', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const clsCallback = mockOnCLS.mock.calls[0]![0]; + clsCallback({ name: 'CLS', value: 0.5, delta: 0.5, id: 'cls-3' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('poor'); + }); + }); + + // TTFB thresholds: good <= 800, needs-improvement <= 1800, poor > 1800 + describe('TTFB ratings', () => { + it('should rate TTFB as good when value <= 800', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; + ttfbCallback({ name: 'TTFB', value: 800, delta: 800, id: 'ttfb-1' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('good'); + }); + + it('should rate TTFB as needs-improvement when 800 < value <= 1800', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; + ttfbCallback({ name: 'TTFB', value: 1200, delta: 1200, id: 'ttfb-2' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); + }); + + it('should rate TTFB as poor when value > 1800', () => { + const reporter = vi.fn(); + webVitalsModule.initWebVitals(reporter); + + const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; + ttfbCallback({ name: 'TTFB', value: 2500, delta: 2500, id: 'ttfb-3' }); + + expect(reporter.mock.calls[0]![0].rating).toBe('poor'); + }); + }); + }); + + // ========================================================================== + // createDefaultReporter + // ========================================================================== + describe('createDefaultReporter', () => { + it('should return a function', () => { + const reporter = webVitalsModule.createDefaultReporter({}); + + expect(typeof reporter).toBe('function'); + }); + + it('should log to console when debug is true', () => { + const reporter = webVitalsModule.createDefaultReporter({ debug: true }); + + reporter({ + name: 'LCP', + value: 2100.5, + rating: 'good', + delta: 2100.5, + id: 'v1-lcp' + }); + + expect(console.log).toHaveBeenCalledWith( + '[WebVitals] LCP: 2100.50 (good)' + ); + }); + + it('should not log to console when debug is false', () => { + const reporter = webVitalsModule.createDefaultReporter({ debug: false }); + + reporter({ + name: 'LCP', + value: 2100, + rating: 'good', + delta: 2100, + id: 'v1-lcp' + }); + + expect(console.log).not.toHaveBeenCalled(); + }); + + it('should not log to console when debug is not set', () => { + const reporter = webVitalsModule.createDefaultReporter({}); + + reporter({ + name: 'FCP', + value: 1500, + rating: 'good', + delta: 1500, + id: 'v1-fcp' + }); + + expect(console.log).not.toHaveBeenCalled(); + }); + + it('should use sendBeacon when endpoint is set and sendBeacon is available', () => { + const mockSendBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); + + const reporter = webVitalsModule.createDefaultReporter({ + endpoint: 'https://analytics.classeo.fr/vitals' + }); + + const metric = { + name: 'CLS' as const, + value: 0.05, + rating: 'good' as const, + delta: 0.05, + id: 'v1-cls' + }; + + reporter(metric); + + expect(mockSendBeacon).toHaveBeenCalledWith( + 'https://analytics.classeo.fr/vitals', + expect.any(String) + ); + + // Verify the payload structure + const payload = JSON.parse(mockSendBeacon.mock.calls[0]![1]); + expect(payload).toEqual( + expect.objectContaining({ + metric: 'CLS', + value: 0.05, + rating: 'good', + delta: 0.05, + id: 'v1-cls' + }) + ); + expect(payload.timestamp).toEqual(expect.any(Number)); + expect(payload.url).toEqual(expect.any(String)); + }); + + it('should fall back to fetch when sendBeacon is not available', () => { + vi.stubGlobal('navigator', { sendBeacon: undefined }); + const mockFetch = vi.fn().mockResolvedValue({}); + vi.stubGlobal('fetch', mockFetch); + + const reporter = webVitalsModule.createDefaultReporter({ + endpoint: 'https://analytics.classeo.fr/vitals' + }); + + reporter({ + name: 'LCP', + value: 2000, + rating: 'good', + delta: 2000, + id: 'v1-lcp' + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://analytics.classeo.fr/vitals', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + keepalive: true + }) + ); + }); + + it('should not send data when no endpoint is configured', () => { + const mockSendBeacon = vi.fn(); + vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + const reporter = webVitalsModule.createDefaultReporter({}); + + reporter({ + name: 'INP', + value: 100, + rating: 'good', + delta: 100, + id: 'v1-inp' + }); + + expect(mockSendBeacon).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should support both debug and endpoint at the same time', () => { + const mockSendBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); + + const reporter = webVitalsModule.createDefaultReporter({ + debug: true, + endpoint: 'https://analytics.classeo.fr/vitals' + }); + + reporter({ + name: 'TTFB', + value: 500.123, + rating: 'good', + delta: 500.123, + id: 'v1-ttfb' + }); + + expect(console.log).toHaveBeenCalledWith( + '[WebVitals] TTFB: 500.12 (good)' + ); + expect(mockSendBeacon).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/tests/unit/lib/types/shared.test.ts b/frontend/tests/unit/lib/types/shared.test.ts new file mode 100644 index 0000000..b729f81 --- /dev/null +++ b/frontend/tests/unit/lib/types/shared.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + createTenantId, + createUserId, + createNoteId, + createClasseId, + createEleveId +} from '$lib/types/shared'; + +/** + * Unit tests for branded type helper functions. + * + * Branded types provide compile-time type safety (e.g. preventing + * accidental use of a UserId where a TenantId is expected). + * At runtime, the create functions are identity functions that + * simply return the input string. + */ + +describe('shared branded types', () => { + // ========================================================================== + // createTenantId + // ========================================================================== + describe('createTenantId', () => { + it('should return a string', () => { + const result = createTenantId('tenant-abc-123'); + expect(typeof result).toBe('string'); + }); + + it('should preserve the input value', () => { + const input = 'b2c3d4e5-f6a7-8901-bcde-f23456789012'; + const result = createTenantId(input); + expect(result).toBe(input); + }); + + it('should handle empty string', () => { + const result = createTenantId(''); + expect(result).toBe(''); + }); + }); + + // ========================================================================== + // createUserId + // ========================================================================== + describe('createUserId', () => { + it('should return a string', () => { + const result = createUserId('user-xyz-456'); + expect(typeof result).toBe('string'); + }); + + it('should preserve the input value', () => { + const input = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const result = createUserId(input); + expect(result).toBe(input); + }); + }); + + // ========================================================================== + // createNoteId + // ========================================================================== + describe('createNoteId', () => { + it('should return a string', () => { + const result = createNoteId('note-001'); + expect(typeof result).toBe('string'); + }); + + it('should preserve the input value', () => { + const input = 'd4e5f6a7-b890-1234-cdef-567890abcdef'; + const result = createNoteId(input); + expect(result).toBe(input); + }); + }); + + // ========================================================================== + // createClasseId + // ========================================================================== + describe('createClasseId', () => { + it('should return a string', () => { + const result = createClasseId('classe-6eme-a'); + expect(typeof result).toBe('string'); + }); + + it('should preserve the input value', () => { + const input = 'e5f6a7b8-9012-3456-cdef-890abcdef123'; + const result = createClasseId(input); + expect(result).toBe(input); + }); + }); + + // ========================================================================== + // createEleveId + // ========================================================================== + describe('createEleveId', () => { + it('should return a string', () => { + const result = createEleveId('eleve-jean-dupont'); + expect(typeof result).toBe('string'); + }); + + it('should preserve the input value', () => { + const input = 'f6a7b890-1234-5678-cdef-abcdef012345'; + const result = createEleveId(input); + expect(result).toBe(input); + }); + }); + + // ========================================================================== + // Type safety (compile-time checks) + // ========================================================================== + describe('type safety', () => { + it('should produce values that are assignable to string', () => { + // These assignments verify that branded types are assignable to string + const tenantId: string = createTenantId('t1'); + const userId: string = createUserId('u1'); + const noteId: string = createNoteId('n1'); + const classeId: string = createClasseId('c1'); + const eleveId: string = createEleveId('e1'); + + // Use all variables to avoid unused-variable warnings + expect(tenantId).toBe('t1'); + expect(userId).toBe('u1'); + expect(noteId).toBe('n1'); + expect(classeId).toBe('c1'); + expect(eleveId).toBe('e1'); + }); + + it('should be usable in string operations', () => { + const userId = createUserId('user-123'); + // Branded types should work in all string contexts + expect(userId.startsWith('user-')).toBe(true); + expect(userId.length).toBe(8); + expect(`ID: ${userId}`).toBe('ID: user-123'); + }); + }); +});