From fdc26eb3348c6f4e8379565210788a5321874470 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 15 Feb 2026 18:44:33 +0100 Subject: [PATCH] test: Ajouter les tests unitaires manquants (backend et frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couverture des processors (RefreshToken, RequestPasswordReset, ResetPassword, SwitchRole, UpdateUserRoles), des query handlers (HasGradesInPeriod, HasStudentsInClass), des messaging handlers (SendActivationConfirmation, SendPasswordResetEmail), et côté frontend des modules auth, roles, monitoring, types et E2E tokens. --- .../HasGradesInPeriodHandlerTest.php | 96 ++++ .../HasStudentsInClassHandlerTest.php | 60 +++ .../Processor/RefreshTokenProcessorTest.php | 426 ++++++++++++++++ .../RequestPasswordResetProcessorTest.php | 315 ++++++++++++ .../Processor/ResetPasswordProcessorTest.php | 208 ++++++++ .../Api/Processor/SwitchRoleProcessorTest.php | 263 ++++++++++ .../UpdateUserRolesProcessorTest.php | 321 ++++++++++++ .../SendActivationConfirmationHandlerTest.php | 195 +++++++ .../SendPasswordResetEmailHandlerTest.php | 203 ++++++++ frontend/e2e/token-edge-cases.spec.ts | 201 ++++++++ frontend/tests/unit/lib/api/config.test.ts | 168 +++++++ .../unit/lib/constants/schoolLevels.test.ts | 129 +++++ .../unit/lib/features/roles/api/roles.test.ts | 178 +++++++ .../lib/features/roles/roleContext.test.ts | 351 +++++++++++++ .../tests/unit/lib/monitoring/sentry.test.ts | 389 ++++++++++++++ .../unit/lib/monitoring/webVitals.test.ts | 476 ++++++++++++++++++ frontend/tests/unit/lib/types/shared.test.ts | 133 +++++ 17 files changed, 4112 insertions(+) create mode 100644 backend/tests/Unit/Administration/Application/Query/HasGradesInPeriod/HasGradesInPeriodHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/HasStudentsInClass/HasStudentsInClassHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/RefreshTokenProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/RequestPasswordResetProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/ResetPasswordProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/SwitchRoleProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/UpdateUserRolesProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Messaging/SendActivationConfirmationHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Messaging/SendPasswordResetEmailHandlerTest.php create mode 100644 frontend/e2e/token-edge-cases.spec.ts create mode 100644 frontend/tests/unit/lib/api/config.test.ts create mode 100644 frontend/tests/unit/lib/constants/schoolLevels.test.ts create mode 100644 frontend/tests/unit/lib/features/roles/api/roles.test.ts create mode 100644 frontend/tests/unit/lib/features/roles/roleContext.test.ts create mode 100644 frontend/tests/unit/lib/monitoring/sentry.test.ts create mode 100644 frontend/tests/unit/lib/monitoring/webVitals.test.ts create mode 100644 frontend/tests/unit/lib/types/shared.test.ts 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'); + }); + }); +});