diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraint.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..677de9e73974bc0ac8d7b44782946079918840b8 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraint.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\Validator\Constraint as SymfonyConstraint; + +/** + * Checks if a method on a service or instantiated object returns true. + * + * For example to call the method 'isValidScheme' on the service + * 'stream_wrapper_manager', use: ['stream_wrapper_manager', 'isValidScheme']. + * It is also possible to use a class if it implements + * ContainerInjectionInterface. It will use the ClassResolver to resolve the + * class and return an instance. Then it will call the configured method on + * that instance. + * + * The called method should return TRUE when the result is valid. All other + * values will be considered as invalid. + */ +#[Constraint( + id: 'ClassResolver', + label: new TranslatableMarkup('Call a method on a service', [], ['context' => 'Validation']), + type: FALSE, +)] +class ClassResolverConstraint extends SymfonyConstraint { + + /** + * The error message if validation fails. + * + * @var string + */ + public string $message = "Calling '@method' method with value '@value' on '@classOrService' evaluated as invalid."; + + /** + * Class or service. + * + * @var array + */ + public string $classOrService; + + /** + * Method to call. + * + * @var string + */ + public string $method; + + /** + * {@inheritdoc} + */ + public function getRequiredOptions(): array { + return ['classOrService', 'method']; + } + +} diff --git a/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraintValidator.php b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..6a4215b19f57f485cb774dddacb7aa3082a379f6 --- /dev/null +++ b/core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/ClassResolverConstraintValidator.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Core\Validation\Plugin\Validation\Constraint; + +use Drupal\Core\DependencyInjection\ClassResolver; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * Validates if a method on a service or instantiated object returns true. + */ +class ClassResolverConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface { + + public function __construct(protected ClassResolver $classResolver) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get('class_resolver') + ); + } + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void { + + if (!$constraint instanceof ClassResolverConstraint) { + throw new UnexpectedTypeException($constraint, ClassResolverConstraint::class); + } + $service = $this->classResolver->getInstanceFromDefinition($constraint->classOrService); + + if (!method_exists($service, $constraint->method)) { + throw new \InvalidArgumentException('The method "' . $constraint->method . '" does not exist on the service "' . $constraint->classOrService . '".'); + } + + $result = $service->{$constraint->method}($value); + if ($result !== TRUE) { + $this->context->buildViolation($constraint->message) + ->setParameter('@classOrService', $constraint->classOrService) + ->setParameter('@method', $constraint->method) + ->setParameter('@value', $value) + ->addViolation(); + } + } + +} diff --git a/core/modules/image/tests/src/Kernel/ImageStyleCustomStreamWrappersTest.php b/core/modules/image/tests/src/Kernel/ImageStyleCustomStreamWrappersTest.php index f9b6ab641805b4fc4286a5b86a9a86a84f53d34b..88e25d8ee4830bedc58b4eb01bd679ddd4104ade 100644 --- a/core/modules/image/tests/src/Kernel/ImageStyleCustomStreamWrappersTest.php +++ b/core/modules/image/tests/src/Kernel/ImageStyleCustomStreamWrappersTest.php @@ -46,7 +46,11 @@ class ImageStyleCustomStreamWrappersTest extends KernelTestBase { protected function setUp(): void { parent::setUp(); $this->fileSystem = $this->container->get('file_system'); - $this->config('system.file')->set('default_scheme', 'public')->save(); + $this->config('system.file') + ->set('default_scheme', 'public') + ->set('allow_insecure_uploads', FALSE) + ->set('temporary_maximum_age', 21600) + ->save(); $this->imageStyle = ImageStyle::create([ 'name' => 'test', 'label' => 'Test', diff --git a/core/modules/system/config/schema/system.schema.yml b/core/modules/system/config/schema/system.schema.yml index 65ddbe0c32b797062ade56372bd4cc6deb7870a6..8d19b0dd2aa0ad5f386682e15ca3bb6d859a6c9e 100644 --- a/core/modules/system/config/schema/system.schema.yml +++ b/core/modules/system/config/schema/system.schema.yml @@ -334,6 +334,8 @@ system.action.*: system.file: type: config_object label: 'File system' + constraints: + FullyValidatable: ~ mapping: allow_insecure_uploads: type: boolean @@ -341,6 +343,10 @@ system.file: default_scheme: type: string label: 'Default download method' + constraints: + ClassResolver: + classOrService: 'stream_wrapper_manager' + method: 'isValidScheme' path: type: mapping label: 'Path settings' @@ -348,6 +354,9 @@ system.file: temporary_maximum_age: type: integer label: 'Maximum age for temporary files' + constraints: + Range: + min: 0 system.image: type: config_object diff --git a/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php b/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php index 16fc7dfe3381e5939eae01e86436ef335613c354..ff5a8b02d457fbe846decb5acf503685e04490b7 100644 --- a/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php +++ b/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php @@ -13,6 +13,16 @@ */ class UpdateDeleteFileIfStaleTest extends KernelTestBase { + /** + * Disable strict config schema checking. + * + * This test requires saving invalid configuration. This allows for the + * simulation of a temporary file becoming stale. + * + * @var bool + */ + protected $strictConfigSchema = FALSE; + /** * {@inheritdoc} */ diff --git a/core/tests/Drupal/KernelTests/Core/File/FileTestBase.php b/core/tests/Drupal/KernelTests/Core/File/FileTestBase.php index d41860c72f92bf50a263c0965d4ebf0b9d932d79..5745e6a38a91a3b9cd39bc0ad150aefe6a70c1af 100644 --- a/core/tests/Drupal/KernelTests/Core/File/FileTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/File/FileTestBase.php @@ -41,7 +41,11 @@ protected function setUp(): void { // file_default_scheme(). As we are creating the configuration here remove // the global override. unset($GLOBALS['config']['system.file']); - \Drupal::configFactory()->getEditable('system.file')->set('default_scheme', 'public')->save(); + \Drupal::configFactory()->getEditable('system.file') + ->set('default_scheme', 'public') + ->set('allow_insecure_uploads', FALSE) + ->set('temporary_maximum_age', 21600) + ->save(); } /** diff --git a/core/tests/Drupal/KernelTests/Core/TypedData/ClassResolverConstraintValidatorTest.php b/core/tests/Drupal/KernelTests/Core/TypedData/ClassResolverConstraintValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..000f831117410d4a682a5cb9442408bb6f844bc5 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/TypedData/ClassResolverConstraintValidatorTest.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\TypedData; + +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\TypedDataManagerInterface; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests ClassResolver validation constraint with both valid and invalid values. + * + * @covers \Drupal\Core\Validation\Plugin\Validation\Constraint\ClassResolverConstraintValidator + * @group Validation + */ +class ClassResolverConstraintValidatorTest extends KernelTestBase { + + /** + * The typed data manager to use. + * + * @var \Drupal\Core\TypedData\TypedDataManager + */ + protected TypedDataManagerInterface $typedData; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->typedData = $this->container->get('typed_data_manager'); + $this->container->set('test.service', new class() { + + /** + * Dummy method to return TRUE. + * + * @return bool + * TRUE. + */ + public function returnTrue(): bool { + return TRUE; + } + + /** + * Dummy method to return FALSE. + * + * @return bool + * FALSE. + */ + public function returnFalse(): bool { + return FALSE; + } + + /** + * Dummy method to return a truthy value. + * + * @return string + * A string that evaluates to TRUE. + */ + public function returnNotTrue(): string { + return 'true'; + } + + }); + + } + + /** + * Data provider for service validation test cases. + */ + public static function provideServiceValidationCases(): array { + return [ + 'false result' => [ + 'method' => 'returnFalse', + 'expected_violations' => 1, + 'message' => 'Validation failed when returning FALSE.', + 'expected_violation_message' => 'Calling \'returnFalse\' method with value \'1\' on \'test.service\' evaluated as invalid.', + ], + 'true result' => [ + 'method' => 'returnTrue', + 'expected_violations' => 0, + 'message' => 'Validation succeeds when returning TRUE.', + ], + 'truthy result' => [ + 'method' => 'returnNotTrue', + 'expected_violations' => 1, + 'message' => 'Validation fails when returning \'true\'.', + 'expected_violation_message' => 'Calling \'returnNotTrue\' method with value \'1\' on \'test.service\' evaluated as invalid.', + ], + ]; + } + + /** + * @dataProvider provideServiceValidationCases + */ + public function testValidationForService(string $method, int $expected_violations, string $message, ?string $expected_violation_message = NULL): void { + $definition = DataDefinition::create('integer') + ->addConstraint('ClassResolver', ['classOrService' => 'test.service', 'method' => $method]); + $typed_data = $this->typedData->create($definition, 1); + $violations = $typed_data->validate(); + $this->assertEquals($expected_violations, $violations->count(), $message); + if ($expected_violation_message) { + $this->assertEquals($expected_violation_message, $violations->get(0)->getMessage()); + } + } + + /** + * Test missing method case. + * + * Tests that the ClassResolver constraint throws an exception when the + * method does not exist. + */ + public function testNonExistingMethod(): void { + $definition = DataDefinition::create('integer') + ->addConstraint('ClassResolver', ['classOrService' => 'test.service', 'method' => 'missingMethod']); + $typed_data = $this->typedData->create($definition, 1); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The method "missingMethod" does not exist on the service "test.service".'); + $typed_data->validate(); + } + + /** + * Test missing class case. + * + * Tests that the ClassResolver constraint throws an exception when the + * class does not exist. + */ + public function testNonExistingClass(): void { + $definition = DataDefinition::create('integer') + ->addConstraint('ClassResolver', ['classOrService' => '\Drupal\NonExisting\Class', 'method' => 'boo']); + $typed_data = $this->typedData->create($definition, 1); + + $this->expectExceptionMessage('Class "\Drupal\NonExisting\Class" does not exist.'); + $typed_data->validate(); + } + +}