diff --git a/src/Element/Conditions/RelatedToConditionRule.php b/src/Element/Conditions/RelatedToConditionRule.php index d7ac474363b..364824d0067 100644 --- a/src/Element/Conditions/RelatedToConditionRule.php +++ b/src/Element/Conditions/RelatedToConditionRule.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Field\BaseRelationField; use CraftCms\Cms\Field\Fields; @@ -110,7 +111,7 @@ private function _elementTypeOptions(): array public function getRules(): array { return array_merge(parent::getRules(), [ - 'elementType' => ['required', 'string'], + 'elementType' => ['required', 'string', new ElementTypeRule], ]); } diff --git a/src/Element/Validation/Rules/ElementTypeRule.php b/src/Element/Validation/Rules/ElementTypeRule.php new file mode 100644 index 00000000000..39b66d3240b --- /dev/null +++ b/src/Element/Validation/Rules/ElementTypeRule.php @@ -0,0 +1,39 @@ +getMessage(); + } + + #[\Override] + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (self::isValid($value)) { + return; + } + + $fail(self::message($value)); + } +} diff --git a/src/Http/Controllers/App/RenderController.php b/src/Http/Controllers/App/RenderController.php index eb36298814f..0737ac745d9 100644 --- a/src/Http/Controllers/App/RenderController.php +++ b/src/Http/Controllers/App/RenderController.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Field\Data\MarkdownData; use CraftCms\Cms\Field\Markdown as MarkdownField; use CraftCms\Cms\Markdown\Markdown as MarkdownService; @@ -37,7 +38,7 @@ public function elements(Request $request): JsonResponse { $criteria = $request->validate([ 'elements' => ['required', 'array'], - 'elements.*.type' => ['required', 'string'], + 'elements.*.type' => ['required', 'string', new ElementTypeRule], 'elements.*.id' => ['required'], 'elements.*.siteId' => ['required'], 'elements.*.instances' => ['required', 'array'], diff --git a/src/Http/Controllers/Elements/DeleteElementsController.php b/src/Http/Controllers/Elements/DeleteElementsController.php index 9af7386df8c..90b02c58c0f 100644 --- a/src/Http/Controllers/Elements/DeleteElementsController.php +++ b/src/Http/Controllers/Elements/DeleteElementsController.php @@ -16,6 +16,7 @@ use CraftCms\Cms\Element\Jobs\ReplaceReferences; use CraftCms\Cms\Element\Jobs\ReplaceRelations; use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Field\FieldReferences; use CraftCms\Cms\Http\Requests\ElementRequest; use CraftCms\Cms\Http\RespondsWithFlash; @@ -111,7 +112,7 @@ public function destroy(Elements $elementsService): JsonResponse public function replaceRelationsModal(): CpModalResponse { $this->request->validate([ - 'sourceElementType' => ['required', 'string'], + 'sourceElementType' => ['required', 'string', new ElementTypeRule], ]); /** @var class-string $sourceElementType */ @@ -142,7 +143,7 @@ public function replaceRelationsModal(): CpModalResponse public function replaceRelations(): Response { $this->request->validate([ - 'sourceElementType' => ['required', 'string'], + 'sourceElementType' => ['required', 'string', new ElementTypeRule], 'newTargetId' => ['required', 'integer'], ]); diff --git a/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php b/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php index 6f40d8b8840..d9928b79544 100644 --- a/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php +++ b/src/Http/Controllers/Elements/ElementIndex/ExportElementIndexController.php @@ -4,12 +4,11 @@ namespace CraftCms\Cms\Http\Controllers\Elements\ElementIndex; -use Closure; use CraftCms\Cms\Element\Contracts\ElementExporterInterface; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\ElementExporters; -use CraftCms\Cms\Element\Exceptions\InvalidTypeException; use CraftCms\Cms\Element\Exporters\Raw; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Http\Controllers\Elements\Concerns\InteractsWithElementIndexes; use CraftCms\Cms\Http\Requests\ElementIndexRequest; use Symfony\Component\HttpFoundation\Response; @@ -29,11 +28,7 @@ public function __invoke(): Response 'elementType' => [ 'required', 'string', - function (string $attribute, mixed $value, Closure $fail): void { - if (! is_string($value) || ! is_subclass_of($value, ElementInterface::class)) { - $fail(new InvalidTypeException((string) $value, ElementInterface::class)->getMessage()); - } - }, + new ElementTypeRule, ], 'type' => ['sometimes', 'string'], 'format' => ['sometimes', 'string'], diff --git a/src/Http/Controllers/FieldsController.php b/src/Http/Controllers/FieldsController.php index 66746d50a58..f1e27ae3a9b 100644 --- a/src/Http/Controllers/FieldsController.php +++ b/src/Http/Controllers/FieldsController.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Cp\Html\ContentHtml; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\Field\Field; @@ -336,7 +337,7 @@ private function fieldLayoutComponent(Request $request, ?array &$settings = null { $request->validate([ 'uid' => ['required', 'string'], - 'elementType' => ['required', 'string'], + 'elementType' => ['required', 'string', new ElementTypeRule], 'layoutConfig' => ['required', 'array'], 'config' => ['nullable', 'array'], 'settings' => ['nullable', 'string'], diff --git a/src/Http/Controllers/MatrixController.php b/src/Http/Controllers/MatrixController.php index 909005da62c..c8ca69f2226 100644 --- a/src/Http/Controllers/MatrixController.php +++ b/src/Http/Controllers/MatrixController.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\EntryQuery; use CraftCms\Cms\Element\Validation\ElementRules; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\EntryTypes; use CraftCms\Cms\Field\Matrix; @@ -63,7 +64,7 @@ public function createEntry(Request $request): Response 'fieldId' => ['required'], 'entryTypeId' => ['required'], 'ownerId' => ['required'], - 'ownerElementType' => ['required'], + 'ownerElementType' => ['required', 'string', new ElementTypeRule], 'siteId' => ['required'], 'namespace' => ['required'], 'staticEntries' => ['nullable', 'boolean'], diff --git a/src/Http/Controllers/RelationalFieldsController.php b/src/Http/Controllers/RelationalFieldsController.php index 5aa0c2d6b45..9597a5e5284 100644 --- a/src/Http/Controllers/RelationalFieldsController.php +++ b/src/Http/Controllers/RelationalFieldsController.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Structure\Structures; use CraftCms\Cms\Support\Facades\HtmlStack; use Illuminate\Http\JsonResponse; @@ -21,7 +22,7 @@ public function structuredInputHtml( Structures $structures, ): JsonResponse { $request->validate([ - 'elementType' => ['required', 'string'], + 'elementType' => ['required', 'string', new ElementTypeRule], 'elementIds' => ['nullable', 'array'], 'siteId' => ['nullable'], 'branchLimit' => ['nullable', 'integer'], diff --git a/src/Http/Requests/ElementIndexRequest.php b/src/Http/Requests/ElementIndexRequest.php index 9a1346c125b..5b0fa827712 100644 --- a/src/Http/Requests/ElementIndexRequest.php +++ b/src/Http/Requests/ElementIndexRequest.php @@ -5,12 +5,11 @@ namespace CraftCms\Cms\Http\Requests; use Closure; -use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\ElementSources; -use CraftCms\Cms\Element\Exceptions\InvalidTypeException; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Support\Facades\Conditions; use CraftCms\Cms\Support\Facades\Elements; use Illuminate\Foundation\Http\FormRequest; @@ -27,11 +26,7 @@ class ElementIndexRequest extends FormRequest public function elementType(): string { $this->validate([ - 'elementType' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void { - if (! ComponentHelper::validateComponentClass($value, ElementInterface::class)) { - $fail(new InvalidTypeException((string) $value, ElementInterface::class)->getMessage()); - } - }], + 'elementType' => ['required', 'string', new ElementTypeRule], ]); return $this->input('elementType'); diff --git a/src/Http/Requests/ElementRequest.php b/src/Http/Requests/ElementRequest.php index e0aa4bcaf60..1f35d4aa16a 100644 --- a/src/Http/Requests/ElementRequest.php +++ b/src/Http/Requests/ElementRequest.php @@ -4,12 +4,11 @@ namespace CraftCms\Cms\Http\Requests; -use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Cp\RequestedSite; use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Element\Exceptions\InvalidTypeException; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Sites; @@ -188,11 +187,11 @@ private function elementQuery(bool $withNestedContext = true): ElementQueryInter public function validateElementType(string $elementType): void { - if (ComponentHelper::validateComponentClass($elementType, ElementInterface::class)) { + if (ElementTypeRule::isValid($elementType)) { return; } - abort(400, new InvalidTypeException($elementType, ElementInterface::class)->getMessage()); + abort(400, ElementTypeRule::message($elementType)); } /** diff --git a/src/Http/Requests/NestedElementsRequest.php b/src/Http/Requests/NestedElementsRequest.php index 1393485d596..28a3e38a6d9 100644 --- a/src/Http/Requests/NestedElementsRequest.php +++ b/src/Http/Requests/NestedElementsRequest.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Element\Contracts\NestedElementInterface; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Support\Facades\Elements; use Illuminate\Foundation\Http\FormRequest; @@ -28,7 +29,7 @@ class NestedElementsRequest extends FormRequest public function rules(): array { return [ - 'ownerElementType' => ['required', 'string'], + 'ownerElementType' => ['required', 'string', new ElementTypeRule], 'ownerId' => ['required', 'integer'], 'ownerSiteId' => ['required', 'integer'], 'attribute' => ['required', 'string'], diff --git a/src/Image/Data/ImageTransform.php b/src/Image/Data/ImageTransform.php index 21404a2278f..fee3cd641de 100644 --- a/src/Image/Data/ImageTransform.php +++ b/src/Image/Data/ImageTransform.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Component\Component; use CraftCms\Cms\Image\Contracts\ImageTransformerInterface; use CraftCms\Cms\Image\ImageTransformer; +use CraftCms\Cms\Validation\Rules\HandleRule; use DateTimeInterface; use Illuminate\Validation\Rule; use Override; @@ -141,7 +142,7 @@ public function getRules(): array { return [ 'name' => ['required', 'string'], - 'handle' => ['required', 'string'], + 'handle' => ['required', 'string', new HandleRule], 'width' => ['nullable', 'integer', 'min:1'], 'height' => ['nullable', 'integer', 'min:1'], 'mode' => ['required', Rule::in(self::MODES)], diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 1b5663591af..d87b63ba84e 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -21,6 +21,7 @@ use CraftCms\Cms\Update\Data\UpdateRelease; use CraftCms\Cms\Update\Data\Updates as UpdatesData; use CraftCms\Cms\User\Contracts\CraftUser; +use CraftCms\Cms\User\Validation\Rules\UserPasswordRule; use GuzzleHttp\Utils; use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\SessionGuard; @@ -55,9 +56,9 @@ class AppServiceProvider extends ServiceProvider { - public static int $minPasswordLength = 8; + public static int $minPasswordLength = UserPasswordRule::MIN_PASSWORD_LENGTH; - public static int $maxPasswordLength = 160; + public static int $maxPasswordLength = UserPasswordRule::MAX_PASSWORD_LENGTH; private string $root = __DIR__.'/../..'; diff --git a/src/RouteToken/Data/RouteToken.php b/src/RouteToken/Data/RouteToken.php index e000ed192ac..b375cc21161 100644 --- a/src/RouteToken/Data/RouteToken.php +++ b/src/RouteToken/Data/RouteToken.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Component\Component; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use Illuminate\Validation\Rule; class RouteToken extends Component @@ -34,7 +35,7 @@ class RouteToken extends Component public function getRules(): array { return [ - 'elementType' => ['required', 'string'], + 'elementType' => ['required', 'string', new ElementTypeRule], 'siteId' => ['required', 'integer', Rule::exists(Table::SITES, 'id')], 'canonicalId' => ['nullable', 'required_without:sourceId'], 'sourceId' => ['nullable', 'required_without:canonicalId'], diff --git a/src/User/Validation/Rules/UserPasswordRule.php b/src/User/Validation/Rules/UserPasswordRule.php index 911d8e76b52..6771e8f9785 100644 --- a/src/User/Validation/Rules/UserPasswordRule.php +++ b/src/User/Validation/Rules/UserPasswordRule.php @@ -12,7 +12,7 @@ readonly class UserPasswordRule implements ValidationRule { - public const int MIN_PASSWORD_LENGTH = 6; + public const int MIN_PASSWORD_LENGTH = 8; public const int MAX_PASSWORD_LENGTH = 160; diff --git a/tests/Feature/User/Elements/UserValidationTest.php b/tests/Feature/User/Elements/UserValidationTest.php index 3198f03fbf2..4efa3ae9c99 100644 --- a/tests/Feature/User/Elements/UserValidationTest.php +++ b/tests/Feature/User/Elements/UserValidationTest.php @@ -390,8 +390,8 @@ public function getFieldLayout(): ?FieldLayout expect($user->errors()->has('newPassword'))->toBe($expectError); })->with([ - '5 chars is too short' => ['12345', true], - '6 chars is valid' => ['123456', false], + '7 chars is too short' => ['1234567', true], + '8 chars is valid' => ['12345678', false], '160 chars is valid' => [str_repeat('a', 160), false], '161 chars is too long' => [str_repeat('a', 161), true], 'null is valid' => [null, false], diff --git a/tests/Unit/Element/Validation/Rules/ElementTypeRuleTest.php b/tests/Unit/Element/Validation/Rules/ElementTypeRuleTest.php new file mode 100644 index 00000000000..1a797948759 --- /dev/null +++ b/tests/Unit/Element/Validation/Rules/ElementTypeRuleTest.php @@ -0,0 +1,28 @@ +validate('elementType', $input, function () use (&$valid) { + $valid = false; + }); + + expect($valid)->toBe($expected); +})->with([ + 'element type' => [Entry::class, true], + 'missing class' => ['App\\MissingElement', false], + 'non-element class' => [stdClass::class, false], + 'integer' => [123, false], + 'array' => [['type' => Entry::class], false], +]); + +it('exposes the same validity check used by non-validator callers', function () { + expect(ElementTypeRule::isValid(Entry::class))->toBeTrue() + ->and(ElementTypeRule::isValid(stdClass::class))->toBeFalse(); +}); diff --git a/tests/Unit/Image/Data/ImageTransformTest.php b/tests/Unit/Image/Data/ImageTransformTest.php index 96e6d2133a5..3a3261fbb6c 100644 --- a/tests/Unit/Image/Data/ImageTransformTest.php +++ b/tests/Unit/Image/Data/ImageTransformTest.php @@ -185,6 +185,21 @@ ->and($transform->errors()->has('handle'))->toBeTrue(); }); + it('requires valid handles', function (string $handle, bool $expected) { + $transform = new ImageTransform([ + 'name' => 'Test', + 'handle' => $handle, + ]); + + expect($transform->validate())->toBe($expected); + })->with([ + 'camel case' => ['validHandle', true], + 'underscore' => ['valid_handle', true], + 'hyphen' => ['invalid-handle', false], + 'leading number' => ['1invalid', false], + 'reserved word' => ['handle', false], + ]); + test('fails with invalid mode', function () { $transform = new ImageTransform([ 'name' => 'Test', diff --git a/tests/Unit/User/Validation/Rules/UserPasswordRuleTest.php b/tests/Unit/User/Validation/Rules/UserPasswordRuleTest.php new file mode 100644 index 00000000000..d2b7f6618ff --- /dev/null +++ b/tests/Unit/User/Validation/Rules/UserPasswordRuleTest.php @@ -0,0 +1,28 @@ +toBe(UserPasswordRule::MIN_PASSWORD_LENGTH) + ->and(AppServiceProvider::$maxPasswordLength)->toBe(UserPasswordRule::MAX_PASSWORD_LENGTH); +}); + +it('validates against the application password length defaults', function (string $password, bool $expected) { + $rule = new UserPasswordRule; + $valid = true; + + $rule->validate('newPassword', $password, function () use (&$valid) { + $valid = false; + }); + + expect($valid)->toBe($expected); +})->with([ + 'empty values are ignored by this rule' => ['', true], + 'below minimum' => ['1234567', false], + 'minimum length' => ['12345678', true], + 'maximum length' => [str_repeat('a', UserPasswordRule::MAX_PASSWORD_LENGTH), true], + 'above maximum' => [str_repeat('a', UserPasswordRule::MAX_PASSWORD_LENGTH + 1), false], +]);