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
*/