diff --git a/src/bundle/Controller/PasswordResetController.php b/src/bundle/Controller/PasswordResetController.php index 75ebc9e..aa01e74 100644 --- a/src/bundle/Controller/PasswordResetController.php +++ b/src/bundle/Controller/PasswordResetController.php @@ -172,6 +172,7 @@ public function userResetPasswordAction(Request $request, string $hashKey): Inva $view = new UserResetPasswordFormView(null, [ 'form_reset_user_password' => $form->createView(), + 'content_type' => $user->getContentType(), ]); $view->setResponse($response); diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index 6ba35ed..0cb2dfa 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -74,3 +74,5 @@ services: Ibexa\User\Form\BaseSubmitHandler: ~ Ibexa\User\Form\SubmitHandler: '@Ibexa\User\Form\BaseSubmitHandler' + + Ibexa\User\Password\PasswordRequirementsResolver: ~ diff --git a/src/bundle/Resources/translations/ibexa_password_requirements.en.xliff b/src/bundle/Resources/translations/ibexa_password_requirements.en.xliff new file mode 100644 index 0000000..9820b30 --- /dev/null +++ b/src/bundle/Resources/translations/ibexa_password_requirements.en.xliff @@ -0,0 +1,46 @@ + + + +
+ + The source node in most cases contains the sample message as written by the developer. If it looks like a dot-delimitted string such as "form.label.firstname", then the developer has not provided a default message. +
+ + + At least one lowercase letter + At least one lowercase letter + key: password_requirement.lower_case + + + At least %length% characters long + At least %length% characters long + key: password_requirement.min_length + + + Different from your current password + Different from your current password + key: password_requirement.new_password + + + At least one special character + At least one special character + key: password_requirement.non_alphanumeric + + + Not found in known data breaches + Not found in known data breaches + key: password_requirement.not_compromised + + + At least one number + At least one number + key: password_requirement.numeric + + + At least one uppercase letter + At least one uppercase letter + key: password_requirement.upper_case + + +
+
diff --git a/src/bundle/Twig/UserExtension.php b/src/bundle/Twig/UserExtension.php index 666bbd5..9d64abd 100644 --- a/src/bundle/Twig/UserExtension.php +++ b/src/bundle/Twig/UserExtension.php @@ -8,13 +8,14 @@ namespace Ibexa\Bundle\User\Twig; +use Override; use Twig\DeprecatedCallableInfo; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; final class UserExtension extends AbstractExtension { - #[\Override] + #[Override] public function getFunctions(): array { return [ @@ -25,6 +26,10 @@ public function getFunctions(): array 'deprecation_info' => new DeprecatedCallableInfo('ibexa/user', '4.6', 'ibexa_current_user'), ] ), + new TwigFunction( + 'ibexa_password_requirements', + [UserRuntime::class, 'getPasswordRequirements'] + ), ]; } } diff --git a/src/bundle/Twig/UserRuntime.php b/src/bundle/Twig/UserRuntime.php index 9c01020..5178bf5 100644 --- a/src/bundle/Twig/UserRuntime.php +++ b/src/bundle/Twig/UserRuntime.php @@ -10,14 +10,17 @@ use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Contracts\Core\Repository\UserService; +use Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType; use Ibexa\Contracts\Core\Repository\Values\User\User; +use Ibexa\User\Password\PasswordRequirementsResolver; use Twig\Extension\RuntimeExtensionInterface; final readonly class UserRuntime implements RuntimeExtensionInterface { public function __construct( private PermissionResolver $permissionResolver, - private UserService $userService + private UserService $userService, + private PasswordRequirementsResolver $passwordRequirementsResolver ) { } @@ -27,4 +30,17 @@ public function getCurrentUser(): User $this->permissionResolver->getCurrentUserReference()->getUserId() ); } + + /** + * @param \Ibexa\Contracts\Core\Repository\Values\ContentType\ContentType|null $contentType required + * on anonymous pages (e.g. password reset); defaults to the current user's content type + * + * @return \Ibexa\User\Password\PasswordRequirement[] + */ + public function getPasswordRequirements(?ContentType $contentType = null): array + { + return $this->passwordRequirementsResolver->getRequirements( + $contentType ?? $this->getCurrentUser()->getContentType() + ); + } } diff --git a/src/lib/Password/PasswordRequirement.php b/src/lib/Password/PasswordRequirement.php new file mode 100644 index 0000000..dda0ab4 --- /dev/null +++ b/src/lib/Password/PasswordRequirement.php @@ -0,0 +1,49 @@ + $parameters + */ + public function __construct( + private string $identifier, + private array $parameters = [] + ) { + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function getTranslationKey(): string + { + return self::TRANSLATION_KEY_PREFIX . $this->identifier; + } +} diff --git a/src/lib/Password/PasswordRequirementsResolver.php b/src/lib/Password/PasswordRequirementsResolver.php new file mode 100644 index 0000000..ff5f1fe --- /dev/null +++ b/src/lib/Password/PasswordRequirementsResolver.php @@ -0,0 +1,102 @@ + requirement identifier and its English label. Adding a rule here + * is all that is needed — translations are generated from this list. + */ + /** Constraint key in the core PasswordValueValidator schema, unlike {@see PasswordRequirement::MIN_LENGTH}. */ + private const string MIN_LENGTH_CONSTRAINT = 'minLength'; + + private const array RULES = [ + self::MIN_LENGTH_CONSTRAINT => [PasswordRequirement::MIN_LENGTH, 'At least %length% characters long'], + 'requireAtLeastOneUpperCaseCharacter' => [PasswordRequirement::UPPER_CASE, 'At least one uppercase letter'], + 'requireAtLeastOneLowerCaseCharacter' => [PasswordRequirement::LOWER_CASE, 'At least one lowercase letter'], + 'requireAtLeastOneNumericCharacter' => [PasswordRequirement::NUMERIC, 'At least one number'], + 'requireAtLeastOneNonAlphanumericCharacter' => [PasswordRequirement::NON_ALPHANUMERIC, 'At least one special character'], + 'requireNewPassword' => [PasswordRequirement::NEW_PASSWORD, 'Different from your current password'], + 'requireNotCompromisedPassword' => [PasswordRequirement::NOT_COMPROMISED, 'Not found in known data breaches'], + ]; + + /** + * @return \Ibexa\User\Password\PasswordRequirement[] + */ + public function getRequirements(ContentType $contentType): array + { + $fieldDefinition = $contentType->getFirstFieldDefinitionOfType(UserType::FIELD_TYPE_IDENTIFIER); + if ($fieldDefinition === null) { + return []; + } + + $constraints = $fieldDefinition->getValidatorConfiguration()['PasswordValueValidator'] ?? []; + $fieldSettings = $fieldDefinition->getFieldSettings(); + + $requirements = []; + foreach (self::RULES as $constraintKey => [$identifier]) { + if ($this->isEnabled($constraintKey, $constraints, $fieldSettings)) { + $requirements[] = new PasswordRequirement($identifier, $this->getParameters($constraintKey, $constraints)); + } + } + + return $requirements; + } + + /** + * @param array $constraints + * @param array $fieldSettings + */ + private function isEnabled(string $constraintKey, array $constraints, array $fieldSettings): bool + { + return match ($constraintKey) { + self::MIN_LENGTH_CONSTRAINT => (int)($constraints[self::MIN_LENGTH_CONSTRAINT] ?? 0) > 0, + // A configured password TTL implies this rule, {@see \Ibexa\Core\FieldType\User\Type::isNewPasswordRequired()} + 'requireNewPassword' => !empty($constraints['requireNewPassword']) + || (int)($fieldSettings[UserType::PASSWORD_TTL_SETTING] ?? 0) > 0, + // Covers boolean on/off flags only; a numeric rule needs its own arm, like minLength above + default => !empty($constraints[$constraintKey]), + }; + } + + /** + * @param array $constraints + * + * @return array + */ + private function getParameters(string $constraintKey, array $constraints): array + { + return $constraintKey === self::MIN_LENGTH_CONSTRAINT + ? ['%length%' => (int)$constraints[self::MIN_LENGTH_CONSTRAINT]] + : []; + } + + /** + * @return \JMS\TranslationBundle\Model\Message[] + */ + public static function getTranslationMessages(): array + { + $messages = []; + foreach (self::RULES as [$identifier, $label]) { + $messages[] = Message::create( + (new PasswordRequirement($identifier))->getTranslationKey(), + 'ibexa_password_requirements' + )->setDesc($label); + } + + return $messages; + } +} diff --git a/src/lib/Validator/Constraints/PasswordValidator.php b/src/lib/Validator/Constraints/PasswordValidator.php index 6b76c4b..2afac26 100644 --- a/src/lib/Validator/Constraints/PasswordValidator.php +++ b/src/lib/Validator/Constraints/PasswordValidator.php @@ -8,14 +8,28 @@ namespace Ibexa\User\Validator\Constraints; -use Ibexa\ContentForms\Validator\ValidationErrorsProcessor; use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Contracts\Core\Repository\Values\User\PasswordValidationContext; +use Ibexa\User\Password\PasswordRequirement; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; class PasswordValidator extends ConstraintValidator { + /** + * Message templates from {@see \Ibexa\Core\Repository\Validator\UserPasswordValidator} + * and {@see \Ibexa\Core\Repository\User\PasswordValidator}. + */ + private const array REQUIREMENT_CODE_MAP = [ + 'User password must be at least %length% characters long' => PasswordRequirement::MIN_LENGTH, + 'User password must include at least one upper case letter' => PasswordRequirement::UPPER_CASE, + 'User password must include at least one lower case letter' => PasswordRequirement::LOWER_CASE, + 'User password must include at least one number' => PasswordRequirement::NUMERIC, + 'User password must include at least one special character' => PasswordRequirement::NON_ALPHANUMERIC, + 'New password cannot be the same as old password' => PasswordRequirement::NEW_PASSWORD, + 'This password has been leaked in a data breach, it must not be used. Please use another password.' => PasswordRequirement::NOT_COMPROMISED, + ]; + public function __construct( private readonly UserService $userService ) { @@ -41,14 +55,21 @@ public function validate(mixed $value, Constraint $constraint): void $value, $passwordValidationContext ); - if (!empty($validationErrors)) { - $validationErrorsProcessor = $this->createValidationErrorsProcessor(); - $validationErrorsProcessor->processValidationErrors($validationErrors); - } - } - protected function createValidationErrorsProcessor(): ValidationErrorsProcessor - { - return new ValidationErrorsProcessor($this->context); + foreach ($validationErrors as $validationError) { + $message = $validationError->getTranslatableMessage(); + $messageTemplate = $message->getMessageTemplate(); + + $violationBuilder = $this->context + ->buildViolation($messageTemplate) + ->setParameters($message->getValues()); + + $code = self::REQUIREMENT_CODE_MAP[$messageTemplate] ?? null; + if ($code !== null) { + $violationBuilder->setCode($code); + } + + $violationBuilder->addViolation(); + } } } diff --git a/tests/bundle/Twig/UserRuntimeTest.php b/tests/bundle/Twig/UserRuntimeTest.php new file mode 100644 index 0000000..d9b6860 --- /dev/null +++ b/tests/bundle/Twig/UserRuntimeTest.php @@ -0,0 +1,117 @@ +permissionResolver = $this->createMock(PermissionResolver::class); + $this->userService = $this->createMock(UserService::class); + + $this->runtime = new UserRuntime( + $this->permissionResolver, + $this->userService, + new PasswordRequirementsResolver() + ); + } + + public function testGetPasswordRequirementsFallsBackToCurrentUserContentType(): void + { + $this->mockCurrentUserWithContentType($this->createContentTypeWithMinLength(10)); + + $requirements = $this->runtime->getPasswordRequirements(); + + self::assertCount(1, $requirements); + self::assertSame(PasswordRequirement::MIN_LENGTH, $requirements[0]->getIdentifier()); + self::assertSame(['%length%' => 10], $requirements[0]->getParameters()); + } + + public function testGetPasswordRequirementsForGivenContentType(): void + { + $this->userService + ->expects(self::never()) + ->method('loadUser'); + + $requirements = $this->runtime->getPasswordRequirements( + $this->createContentTypeWithMinLength(16) + ); + + self::assertCount(1, $requirements); + self::assertSame(PasswordRequirement::MIN_LENGTH, $requirements[0]->getIdentifier()); + self::assertSame(['%length%' => 16], $requirements[0]->getParameters()); + } + + private function createContentTypeWithMinLength(int $minLength): ContentType + { + $fieldDefinition = $this->createMock(FieldDefinition::class); + $fieldDefinition + ->expects(self::once()) + ->method('getValidatorConfiguration') + ->willReturn(['PasswordValueValidator' => ['minLength' => $minLength]]); + $fieldDefinition + ->expects(self::once()) + ->method('getFieldSettings') + ->willReturn([]); + + $contentType = $this->createMock(ContentType::class); + $contentType + ->expects(self::once()) + ->method('getFirstFieldDefinitionOfType') + ->with('ibexa_user') + ->willReturn($fieldDefinition); + + return $contentType; + } + + private function mockCurrentUserWithContentType(ContentType $contentType): void + { + $userReference = $this->createMock(UserReference::class); + $userReference + ->expects(self::once()) + ->method('getUserId') + ->willReturn(self::CURRENT_USER_ID); + + $user = $this->createMock(User::class); + $user + ->expects(self::once()) + ->method('getContentType') + ->willReturn($contentType); + + $this->permissionResolver + ->expects(self::once()) + ->method('getCurrentUserReference') + ->willReturn($userReference); + $this->userService + ->expects(self::once()) + ->method('loadUser') + ->with(self::CURRENT_USER_ID) + ->willReturn($user); + } +} diff --git a/tests/lib/Password/PasswordRequirementsResolverTest.php b/tests/lib/Password/PasswordRequirementsResolverTest.php new file mode 100644 index 0000000..de7e70f --- /dev/null +++ b/tests/lib/Password/PasswordRequirementsResolverTest.php @@ -0,0 +1,195 @@ +resolver = new PasswordRequirementsResolver(); + } + + public function testContentTypeWithoutUserFieldDefinition(): void + { + $contentType = $this->createMock(ContentType::class); + $contentType + ->expects(self::once()) + ->method('getFirstFieldDefinitionOfType') + ->with('ibexa_user') + ->willReturn(null); + + self::assertSame([], $this->resolver->getRequirements($contentType)); + } + + /** + * @dataProvider dataProviderForGetRequirements + * + * @param array $constraints + * @param array $fieldSettings + * @param string[] $expectedIdentifiers + */ + public function testGetRequirements( + array $constraints, + array $fieldSettings, + array $expectedIdentifiers + ): void { + $requirements = $this->resolver->getRequirements( + $this->createContentType($constraints, $fieldSettings) + ); + + self::assertSame( + $expectedIdentifiers, + array_map( + static fn (PasswordRequirement $requirement): string => $requirement->getIdentifier(), + $requirements + ) + ); + } + + /** + * @return array, + * 1: array, + * 2: string[], + * }> + */ + public function dataProviderForGetRequirements(): array + { + return [ + 'all rules disabled' => [ + [ + 'minLength' => null, + 'requireAtLeastOneUpperCaseCharacter' => null, + 'requireAtLeastOneLowerCaseCharacter' => null, + 'requireAtLeastOneNumericCharacter' => null, + 'requireAtLeastOneNonAlphanumericCharacter' => null, + 'requireNewPassword' => null, + 'requireNotCompromisedPassword' => false, + ], + [], + [], + ], + 'all rules enabled' => [ + [ + 'minLength' => 10, + 'requireAtLeastOneUpperCaseCharacter' => 1, + 'requireAtLeastOneLowerCaseCharacter' => 1, + 'requireAtLeastOneNumericCharacter' => 1, + 'requireAtLeastOneNonAlphanumericCharacter' => 1, + 'requireNewPassword' => 1, + 'requireNotCompromisedPassword' => true, + ], + [], + [ + PasswordRequirement::MIN_LENGTH, + PasswordRequirement::UPPER_CASE, + PasswordRequirement::LOWER_CASE, + PasswordRequirement::NUMERIC, + PasswordRequirement::NON_ALPHANUMERIC, + PasswordRequirement::NEW_PASSWORD, + PasswordRequirement::NOT_COMPROMISED, + ], + ], + 'zero min length is disabled' => [ + ['minLength' => 0], + [], + [], + ], + 'new password implied by password TTL' => [ + ['requireNewPassword' => null], + ['PasswordTTL' => 90], + [PasswordRequirement::NEW_PASSWORD], + ], + 'missing validator configuration' => [ + [], + [], + [], + ], + ]; + } + + /** + * Guards against core adding a new rule to the PasswordValueValidator schema + * that this resolver would silently not expose. + */ + public function testCoversEveryCoreValidatorSchemaRule(): void + { + $schema = (new UserType( + $this->createMock(UserHandler::class), + $this->createMock(PasswordHashService::class), + $this->createMock(PasswordValidatorInterface::class) + ))->getValidatorConfigurationSchema()['PasswordValueValidator']; + + $allRulesEnabled = array_map( + static fn (array $rule) => $rule['type'] === 'int' ? 1 : true, + $schema + ); + $allRulesEnabled['minLength'] = 10; + + $requirements = $this->resolver->getRequirements( + $this->createContentType($allRulesEnabled, []) + ); + + self::assertCount( + count($schema), + $requirements, + 'Every rule in the core PasswordValueValidator schema must produce a password requirement.' + ); + } + + public function testMinLengthRequirementCarriesParameters(): void + { + $requirements = $this->resolver->getRequirements( + $this->createContentType(['minLength' => 16], []) + ); + + self::assertCount(1, $requirements); + self::assertSame(PasswordRequirement::MIN_LENGTH, $requirements[0]->getIdentifier()); + self::assertSame(['%length%' => 16], $requirements[0]->getParameters()); + self::assertSame('password_requirement.min_length', $requirements[0]->getTranslationKey()); + } + + /** + * @param array $constraints + * @param array $fieldSettings + */ + private function createContentType(array $constraints, array $fieldSettings): ContentType + { + $fieldDefinition = $this->createMock(FieldDefinition::class); + $fieldDefinition + ->expects(self::once()) + ->method('getValidatorConfiguration') + ->willReturn($constraints === [] ? [] : ['PasswordValueValidator' => $constraints]); + $fieldDefinition + ->expects(self::once()) + ->method('getFieldSettings') + ->willReturn($fieldSettings); + + $contentType = $this->createMock(ContentType::class); + $contentType + ->expects(self::once()) + ->method('getFirstFieldDefinitionOfType') + ->with('ibexa_user') + ->willReturn($fieldDefinition); + + return $contentType; + } +} diff --git a/tests/lib/Validator/Constraint/PasswordValidatorTest.php b/tests/lib/Validator/Constraint/PasswordValidatorTest.php index d2e611c..b3f1882 100644 --- a/tests/lib/Validator/Constraint/PasswordValidatorTest.php +++ b/tests/lib/Validator/Constraint/PasswordValidatorTest.php @@ -13,6 +13,8 @@ use Ibexa\Contracts\Core\Repository\Values\User\PasswordValidationContext; use Ibexa\Contracts\Core\Repository\Values\User\User; use Ibexa\Core\FieldType\ValidationError; +use Ibexa\Core\Repository\Validator\UserPasswordValidator; +use Ibexa\User\Password\PasswordRequirement; use Ibexa\User\Validator\Constraints\Password; use Ibexa\User\Validator\Constraints\PasswordValidator; use PHPUnit\Framework\MockObject\MockObject; @@ -131,15 +133,171 @@ public function testInvalid(): void ->method('setParameters') ->with(['%foo%' => $errorParameter]) ->willReturn($constraintViolationBuilder); + $constraintViolationBuilder + ->expects(self::never()) + ->method('setCode'); + $constraintViolationBuilder + ->expects(self::once()) + ->method('addViolation'); + + $this->validator->validate('pass', new Password([ + 'contentType' => $contentType, + ])); + } + + public function testPluralValidationErrorUsesPluralMessageTemplate(): void + { + $contentType = $this->createMock(ContentType::class); + + $this->userService + ->method('validatePassword') + ->willReturn([ + new ValidationError('singular error', 'plural error', ['%limit%' => 2]), + ]); + + $constraintViolationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $constraintViolationBuilder + ->expects(self::once()) + ->method('setParameters') + ->with(['%limit%' => 2]) + ->willReturn($constraintViolationBuilder); + $constraintViolationBuilder + ->expects(self::never()) + ->method('setCode'); + $constraintViolationBuilder + ->expects(self::once()) + ->method('addViolation'); + + $this->executionContext + ->expects(self::once()) + ->method('buildViolation') + ->with('plural error') + ->willReturn($constraintViolationBuilder); + + $this->validator->validate('pass', new Password([ + 'contentType' => $contentType, + ])); + } + + /** + * @dataProvider dataProviderForKnownValidationErrorsGetRequirementCode + */ + public function testKnownValidationErrorsGetRequirementCode( + string $errorMessage, + string $expectedCode + ): void { + $contentType = $this->createMock(ContentType::class); + + $this->userService + ->method('validatePassword') + ->willReturn([new ValidationError($errorMessage)]); + + $constraintViolationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $constraintViolationBuilder + ->expects(self::once()) + ->method('setParameters') + ->willReturn($constraintViolationBuilder); + $constraintViolationBuilder + ->expects(self::once()) + ->method('setCode') + ->with($expectedCode) + ->willReturn($constraintViolationBuilder); $constraintViolationBuilder ->expects(self::once()) ->method('addViolation'); + $this->executionContext + ->expects(self::once()) + ->method('buildViolation') + ->with($errorMessage) + ->willReturn($constraintViolationBuilder); + $this->validator->validate('pass', new Password([ 'contentType' => $contentType, ])); } + /** + * @return array + */ + public function dataProviderForKnownValidationErrorsGetRequirementCode(): array + { + return [ + 'min length' => [ + 'User password must be at least %length% characters long', + PasswordRequirement::MIN_LENGTH, + ], + 'upper case' => [ + 'User password must include at least one upper case letter', + PasswordRequirement::UPPER_CASE, + ], + 'lower case' => [ + 'User password must include at least one lower case letter', + PasswordRequirement::LOWER_CASE, + ], + 'numeric' => [ + 'User password must include at least one number', + PasswordRequirement::NUMERIC, + ], + 'non alphanumeric' => [ + 'User password must include at least one special character', + PasswordRequirement::NON_ALPHANUMERIC, + ], + 'new password' => [ + 'New password cannot be the same as old password', + PasswordRequirement::NEW_PASSWORD, + ], + 'not compromised' => [ + 'This password has been leaked in a data breach, it must not be used. Please use another password.', + PasswordRequirement::NOT_COMPROMISED, + ], + ]; + } + + /** + * Guards against core rewording validation messages, which would silently + * break the message template → requirement code mapping. + */ + public function testEveryCoreCharacterRuleErrorProducesRequirementCode(): void + { + $coreValidator = new UserPasswordValidator([ + 'minLength' => 10, + 'requireAtLeastOneUpperCaseCharacter' => 1, + 'requireAtLeastOneLowerCaseCharacter' => 1, + 'requireAtLeastOneNumericCharacter' => 1, + 'requireAtLeastOneNonAlphanumericCharacter' => 1, + 'requireNewPassword' => null, + 'requireNotCompromisedPassword' => false, + ]); + $validationErrors = $coreValidator->validate(''); + self::assertCount(5, $validationErrors); + + $this->userService + ->method('validatePassword') + ->willReturn($validationErrors); + + $constraintViolationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $constraintViolationBuilder + ->expects(self::exactly(count($validationErrors))) + ->method('setParameters') + ->willReturn($constraintViolationBuilder); + $constraintViolationBuilder + ->expects(self::exactly(count($validationErrors))) + ->method('setCode') + ->willReturn($constraintViolationBuilder); + $constraintViolationBuilder + ->expects(self::exactly(count($validationErrors))) + ->method('addViolation'); + + $this->executionContext + ->method('buildViolation') + ->willReturn($constraintViolationBuilder); + + $this->validator->validate('pass', new Password([ + 'contentType' => $this->createMock(ContentType::class), + ])); + } + /** * @return array */