Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Schema/FieldValidatorTest.php
T
janniclas.loosenandClaude Opus 5.5 bb61c370a0 fix(security): honour Pods field restrictions (audit M4)
PodsFieldSource maps hidden, read_only, admin_only and
restrict_role/-capability like Pods' own view: hidden -> new status
HIDDEN, read_only -> READONLY, restricted and not permitted -> READONLY
with read_only_restricted, else HIDDEN. HIDDEN fields are not described,
read out, queried or written (they look unknown) but stay in the schema
for the media usage search; write() re-checks before Pods' save().
Required-but-restricted fields no longer block a create. Pods is not
active on DDEV: covered by unit tests with a field-option fixture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:29:29 +02:00

141 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Schema;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Tests\Unit\TestCase;
final class FieldValidatorTest extends TestCase
{
/** @return array<string, FieldDefinition> */
private function definitions(): array
{
return [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
'level' => new FieldDefinition('level', 'Stufe', 'choice', 'pods', 'writable', false, false, [
['value' => 'junior', 'label' => 'Junior'],
['value' => 'senior', 'label' => 'Senior'],
]),
'mentor' => new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable', false, false, null, [
'object_type' => 'post_type',
'name' => 'lit_person',
]),
'photos' => new FieldDefinition('photos', 'Fotos', 'file', 'pods', 'writable', false, true, null, [
'object_type' => 'media',
'name' => null,
]),
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'pin' => new FieldDefinition('pin', 'PIN', 'password', 'pods', 'writable'),
'boss' => new FieldDefinition('boss', 'Boss', 'string', 'pods', FieldDefinition::HIDDEN),
];
}
public function testValidValuesPassUnchanged(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [12 => 'lit_person', 3 => 'attachment'][$id] ?? false);
Functions\when('current_user_can')->justReturn(true);
$values = ['role' => 'Leitung', 'level' => 'senior', 'mentor' => 12, 'photos' => [3], 'start' => '2026-10-01'];
$result = (new FieldValidator())->validate($this->definitions(), $values, true);
self::assertSame([], $result['errors']);
self::assertSame($values, $result['values']);
}
public function testRequiredFieldMustBeSetOnCreateAndNotClearedOnUpdate(): void
{
$validator = new FieldValidator();
self::assertSame('Pflichtfeld fehlt.', $validator->validate($this->definitions(), [], true)['errors']['role']);
self::assertSame(
'Pflichtfeld darf nicht geleert werden.',
$validator->validate($this->definitions(), ['role' => ''], false)['errors']['role']
);
self::assertSame([], $validator->validate($this->definitions(), ['level' => 'junior'], false)['errors']);
}
public function testUnknownReadonlyAndInvalidValuesAreReported(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [7 => 'page', 3 => 'attachment'][$id] ?? false);
Functions\when('current_user_can')->justReturn(true);
$errors = (new FieldValidator())->validate($this->definitions(), [
'role' => 'x',
'nope' => 1,
'secret' => 'x',
'level' => 'expert',
'mentor' => 7,
'photos' => 3,
'start' => '01.10.2026',
'boss' => 'x',
], false)['errors'];
self::assertSame($errors['nope'], $errors['boss'], 'a hidden field looks like an unknown one (M4)');
self::assertStringContainsString('lit-content-types', $errors['nope']);
self::assertSame('Feld ist schreibgeschützt.', $errors['secret']);
self::assertSame('Erlaubte Werte: junior, senior.', $errors['level']);
self::assertSame('Eintrag 7 ist kein Eintrag vom Typ lit_person.', $errors['mentor']);
self::assertSame('Liste erwartet.', $errors['photos']);
self::assertSame('Datum im Format JJJJ-MM-TT erwartet.', $errors['start']);
self::assertArrayNotHasKey('role', $errors);
}
public function testMissingRelatedPostAndNonMediaAreRejected(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [5 => 'page'][$id] ?? false);
Functions\when('current_user_can')->justReturn(true);
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 99, 'photos' => [5]], false)['errors'];
self::assertSame('Eintrag 99 existiert nicht oder ist für dich nicht lesbar.', $errors['mentor']);
self::assertSame('5 ist kein für dich lesbares Medium der Mediathek.', $errors['photos']);
}
public function testUnreadableTargetsLookLikeMissingOnesAndRevealNoType(): void
{
Functions\when('get_post_type')->alias(static fn (int $id) => [8 => 'lit_secret', 4 => 'attachment'][$id] ?? false);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && ! in_array($id, [8, 4], true));
$errors = (new FieldValidator())->validate($this->definitions(), ['mentor' => 8, 'photos' => [4]], false)['errors'];
self::assertSame('Eintrag 8 existiert nicht oder ist für dich nicht lesbar.', $errors['mentor']);
self::assertStringNotContainsString('lit_secret', $errors['mentor']);
self::assertSame('4 ist kein für dich lesbares Medium der Mediathek.', $errors['photos']);
}
public function testPasswordFieldsAreValidatedAsStrings(): void
{
$validator = new FieldValidator();
self::assertSame([], $validator->validate($this->definitions(), ['pin' => '1234'], false)['errors']);
self::assertSame('Text erwartet.', $validator->validate($this->definitions(), ['pin' => 1234], false)['errors']['pin']);
}
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
{
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
self::assertSame([], $result['errors']);
self::assertSame(['city' => ['any' => 'thing'], 'level' => null], $result['values']);
self::assertCount(1, $result['warnings']);
self::assertStringContainsString('city', $result['warnings'][0]);
}
/** M4: a required field the user cannot write (restricted Pods field) cannot block a create. */
public function testRequiredButRestrictedFieldsAreNotDemanded(): void
{
$definitions = [
'boss' => new FieldDefinition('boss', 'Boss', 'string', 'pods', FieldDefinition::HIDDEN, true),
'fixed' => new FieldDefinition('fixed', 'Fest', 'string', 'pods', FieldDefinition::READONLY, true),
];
self::assertSame([], (new FieldValidator())->validate($definitions, [], true)['errors']);
}
}