Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Fields/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

namespace Statamic\Fields;

use Illuminate\Contracts\Validation\CompilableRules;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator as LaravelValidator;
use InvalidArgumentException;
use Statamic\Support\Arr;
use Statamic\Support\Str;

Expand Down Expand Up @@ -157,9 +162,41 @@ private function parseClassBasedRule($rule)

[$class, $arguments] = (new ClassRuleParser)->parse($rule);

$class = ltrim(trim($class), '\\');

if (! $this->isValidationRuleClass($class)) {
throw new InvalidArgumentException("[{$class}] is not a valid validation rule class.");
}

return new $class(...$arguments);
}

private function isValidationRuleClass(string $class): bool
{
if (! preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)*$/', $class)) {
return false;
}

if (! class_exists($class)) {
return false;
}

$contracts = [
Rule::class,
ValidationRule::class,
InvokableRule::class,
CompilableRules::class,
];

foreach ($contracts as $contract) {
if (is_subclass_of($class, $contract)) {
return true;
}
}

return Str::startsWith($class, 'Illuminate\\Validation\\Rules\\');
}

private function parseStringBasedRule($rule)
{
$rule = str_replace('{this}.', $this->context['prefix'] ?? '', $rule);
Expand Down
68 changes: 68 additions & 0 deletions tests/Feature/Fields/FieldActionModalControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace Tests\Feature\Fields;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\User;
use Tests\FakesRoles;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class FieldActionModalControllerTest extends TestCase
{
use FakesRoles;
use PreventSavingStacheItemsToDisk;

private function process(array $fields, array $values)
{
$this->setTestRoles(['test' => ['access cp']]);
$user = User::make()->assignRole('test')->save();

return $this
->actingAs($user)
->postJson('/cp/field-action-modal/process', [
'fields' => $fields,
'values' => $values,
]);
}

#[Test]
public function it_validates_using_the_submitted_rules()
{
$this->process(
['x' => ['type' => 'text', 'validate' => ['required']]],
['x' => null],
)->assertUnprocessable()->assertJsonValidationErrors('x');
}

#[Test]
public function it_does_not_instantiate_classes_that_are_not_validation_rules()
{
FieldActionModalNotARule::$constructed = false;

$this->withoutExceptionHandling();

try {
$this->process(
['x' => ['type' => 'text', 'validate' => ['new \\'.FieldActionModalNotARule::class.'("foo")']]],
['x' => 'y'],
);
$this->fail('Expected an InvalidArgumentException.');
} catch (InvalidArgumentException $e) {
//
}

$this->assertFalse(FieldActionModalNotARule::$constructed);
}
}

class FieldActionModalNotARule
{
public static $constructed = false;

public function __construct(...$args)
{
static::$constructed = true;
}
}
15 changes: 13 additions & 2 deletions tests/Feature/GraphQL/FormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Tests\Feature\GraphQL;

use Closure;
use Facades\Statamic\API\ResourceAuthorizer;
use Facades\Statamic\Fields\BlueprintRepository;
use Illuminate\Contracts\Validation\ValidationRule;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Contracts\GraphQL\CastableToValidationString;
Expand Down Expand Up @@ -373,14 +375,23 @@ public function it_returns_string_based_validation_rules_for_mimes_mimetypes_dim
}
}

class TestValidationRuleWithToString implements CastableToValidationString
class TestValidationRuleWithToString implements CastableToValidationString, ValidationRule
{
public function toGqlValidationString(): string
{
return 'thevalidationrule:foo,bar';
}

public function validate(string $attribute, mixed $value, Closure $fail): void
{
//
}
}

class TestValidationRuleWithoutToString
class TestValidationRuleWithoutToString implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
//
}
}
85 changes: 84 additions & 1 deletion tests/Fields/ValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

namespace Tests\Fields;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\In;
use InvalidArgumentException;
use Mockery;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Fields\Field;
use Statamic\Fields\Fields;
use Statamic\Fields\Validator;
use Statamic\Rules\UniqueEntryValue;
use Tests\TestCase;

class ValidatorTest extends TestCase
Expand Down Expand Up @@ -212,6 +218,68 @@ public function it_makes_class_based_replacements()
$this->assertSame(null, $rule->null);
}

#[Test]
#[DataProvider('validClassBasedRuleProvider')]
public function it_instantiates_validation_rule_classes($rule, $expectedClass)
{
$rules = $this->rulesFor($rule);

$this->assertInstanceOf($expectedClass, $rules['one'][0]);
}

public static function validClassBasedRuleProvider()
{
return [
'validation rule' => ['new Tests\Fields\FakeRule(1, 2, 3, 4, 5, 6)', FakeRule::class],
'validation rule with leading slash' => ['new \Tests\Fields\FakeRule(1, 2, 3, 4, 5, 6)', FakeRule::class],
'statamic rule' => ['new \Statamic\Rules\UniqueEntryValue("blog")', UniqueEntryValue::class],
'laravel rule object' => ['new Illuminate\Validation\Rules\In("a", "b")', In::class],
'laravel rule object with leading slash' => ['new \Illuminate\Validation\Rules\In("a", "b")', In::class],
];
}

#[Test]
#[DataProvider('invalidClassBasedRuleProvider')]
public function it_does_not_instantiate_classes_that_are_not_validation_rules($rule)
{
NotARule::$constructed = false;

try {
$this->rulesFor($rule);
$this->fail('Expected an InvalidArgumentException.');
} catch (InvalidArgumentException $e) {
//
}

$this->assertFalse(NotARule::$constructed);
}

public static function invalidClassBasedRuleProvider()
{
return [
'arbitrary class' => ['new Tests\Fields\NotARule'],
'arbitrary class with arguments' => ['new \Tests\Fields\NotARule("foo")'],
'stringable class' => ['new \Illuminate\Support\HtmlString("foo")'],
'native class' => ['new \SplFileObject("/tmp/foo", "r")'],
'non-existent class' => ['new \Tests\Fields\DoesNotExist'],
'non-existent class in laravel rules namespace' => ['new \Illuminate\Validation\Rules\DoesNotExist'],
'invalid class name' => ['new ../Tests/Fields/NotARule'],
];
}

private function rulesFor($rule)
{
$field = Mockery::mock(Field::class);
$field->shouldReceive('setValidationContext')->with([])->andReturnSelf();
$field->shouldReceive('rules')->andReturn(['one' => [$rule]]);

$fields = Mockery::mock(Fields::class);
$fields->shouldReceive('all')->andReturn(collect([$field]));
$fields->shouldReceive('preProcessValidatables')->andReturnSelf();

return (new Validator)->fields($fields)->rules();
}

#[Test]
public function it_does_not_make_replacements_in_regex_rules()
{
Expand Down Expand Up @@ -493,7 +561,7 @@ public function it_discards_this_at_top_level()
}
}

class FakeRule
class FakeRule implements ValidationRule
{
public function __construct(
public $string,
Expand All @@ -505,4 +573,19 @@ public function __construct(
) {
//
}

public function validate(string $attribute, mixed $value, Closure $fail): void
{
//
}
}

class NotARule
{
public static $constructed = false;

public function __construct(...$args)
{
static::$constructed = true;
}
}
Loading