createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash'); $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail') ->with( self::callback(static fn (Email $email) => (string) $email === 'user@example.com'), self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID) ) ->willReturn($domainUser); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); $securityUser = $provider->loadUserByIdentifier('user@example.com'); self::assertInstanceOf(SecurityUser::class, $securityUser); self::assertSame((string) $domainUser->email, $securityUser->getUserIdentifier()); self::assertSame((string) $domainUser->id, $securityUser->userId()); } #[Test] public function loadUserByIdentifierThrowsForNonExistentUser(): void { $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail')->willReturn(null); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); $this->expectException(UserNotFoundException::class); $provider->loadUserByIdentifier('nonexistent@example.com'); } #[Test] public function loadUserByIdentifierThrowsForInactiveAccount(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $domainUser = $this->createUser($tenantId, StatutCompte::EN_ATTENTE); $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail')->willReturn($domainUser); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); // Should throw because account is not active (AC2: no account existence revelation) $this->expectException(UserNotFoundException::class); $provider->loadUserByIdentifier('user@example.com'); } #[Test] public function loadUserByIdentifierThrowsAccountStatusExceptionForSuspendedUser(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $domainUser = $this->createUser($tenantId, StatutCompte::SUSPENDU, hashedPassword: '$argon2id$hash'); $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail')->willReturn($domainUser); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); $this->expectException(CustomUserMessageAccountStatusException::class); $provider->loadUserByIdentifier('user@example.com'); } #[Test] public function loadUserByIdentifierThrowsForUnknownTenant(): void { $repository = $this->createMock(UserRepository::class); // Repository should not even be called if tenant is unknown $repository->expects(self::never())->method('findByEmail'); $provider = $this->createProvider($repository, 'unknown-tenant.classeo.local'); // Should throw generic error (don't reveal tenant doesn't exist) $this->expectException(UserNotFoundException::class); $provider->loadUserByIdentifier('user@example.com'); } #[Test] public function loadUserByIdentifierUsesCorrectTenantFromRequest(): void { $tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID); $tenantBetaId = TenantId::fromString(self::TENANT_BETA_ID); // User exists in Alpha but not in Beta $domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash'); $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail') ->willReturnCallback(static function (Email $email, TenantId $tenantId) use ($domainUser, $tenantAlphaId) { // Only return user if looking in Alpha tenant if ((string) $tenantId === (string) $tenantAlphaId) { return $domainUser; } return null; }); // Request comes from Beta tenant $provider = $this->createProvider($repository, 'ecole-beta.classeo.local'); // Should throw because user doesn't exist in Beta tenant $this->expectException(UserNotFoundException::class); $provider->loadUserByIdentifier('user@example.com'); } #[Test] public function refreshUserReloadsUserFromRepository(): void { $tenantId = TenantId::fromString(self::TENANT_ALPHA_ID); $domainUser = $this->createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash'); $repository = $this->createMock(UserRepository::class); $repository->expects(self::once()) ->method('findByEmail') ->willReturn($domainUser); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); $factory = new SecurityUserFactory(); $existingSecurityUser = $factory->fromDomainUser($domainUser); $refreshedUser = $provider->refreshUser($existingSecurityUser); self::assertInstanceOf(SecurityUser::class, $refreshedUser); } #[Test] public function supportsClassReturnsTrueForSecurityUser(): void { $repository = $this->createMock(UserRepository::class); $provider = $this->createProvider($repository, 'ecole-alpha.classeo.local'); self::assertTrue($provider->supportsClass(SecurityUser::class)); self::assertFalse($provider->supportsClass(stdClass::class)); } #[Test] public function localhostFallsBackToEcoleAlphaTenant(): void { $tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID); $domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash'); $repository = $this->createMock(UserRepository::class); $repository->method('findByEmail') ->with( self::callback(static fn (Email $email) => (string) $email === 'user@example.com'), // Should use ecole-alpha tenant ID when accessed from localhost self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID) ) ->willReturn($domainUser); // Request from localhost should use ecole-alpha tenant $provider = $this->createProvider($repository, 'localhost'); $securityUser = $provider->loadUserByIdentifier('user@example.com'); self::assertInstanceOf(SecurityUser::class, $securityUser); } private function createProvider(UserRepository $repository, string $host): DatabaseUserProvider { $tenantRegistry = new InMemoryTenantRegistry([ new TenantConfig( TenantId::fromString(self::TENANT_ALPHA_ID), 'ecole-alpha', 'postgresql://localhost/alpha' ), new TenantConfig( TenantId::fromString(self::TENANT_BETA_ID), 'ecole-beta', 'postgresql://localhost/beta' ), ]); $tenantResolver = new TenantResolver($tenantRegistry, 'classeo.local'); $request = Request::create('https://' . $host . '/api/login'); $requestStack = new RequestStack(); $requestStack->push($request); return new DatabaseUserProvider($repository, $tenantResolver, $requestStack, new SecurityUserFactory()); } private function createUser(TenantId $tenantId, StatutCompte $statut, ?string $hashedPassword = null): User { return User::reconstitute( id: UserId::generate(), email: new Email('user@example.com'), role: Role::PARENT, tenantId: $tenantId, schoolName: 'École Test', statut: $statut, dateNaissance: null, createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), hashedPassword: $hashedPassword, activatedAt: $statut === StatutCompte::ACTIF ? new DateTimeImmutable() : null, consentementParental: null, ); } }