fix: relation fields require a readable target and hide its post type

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 01:24:51 +02:00
co-authored by Claude Opus 5.5
parent 106a099483
commit d67a1d4fb3
2 changed files with 29 additions and 9 deletions
@@ -137,28 +137,33 @@ final class FieldValidator
: sprintf('Erlaubte Werte: %s.', implode(', ', $allowed));
}
/**
* The target must exist and be readable for the current user; an unreadable target gets
* the same answer as a missing one and its actual post type is never named (no probing).
*/
private function checkRelation(FieldDefinition $field, mixed $value): ?string
{
if (! is_int($value) || $value <= 0) {
return 'ID (positive Ganzzahl) erwartet.';
}
$actual = get_post_type($value);
$readable = $actual !== false && current_user_can('read_post', $value);
$objectType = $field->related['object_type'] ?? '';
if ($objectType === 'media') {
return get_post_type($value) === 'attachment'
return $readable && $actual === 'attachment'
? null
: sprintf('%d ist kein Medium der Mediathek.', $value);
: sprintf('%d ist kein für dich lesbares Medium der Mediathek.', $value);
}
if ($objectType === 'post_type') {
$actual = get_post_type($value);
if ($actual === false) {
return sprintf('Eintrag %d existiert nicht.', $value);
if (! $readable) {
return sprintf('Eintrag %d existiert nicht oder ist für dich nicht lesbar.', $value);
}
$expected = $field->related['name'] ?? null;
if ($expected !== null && $actual !== $expected) {
return sprintf('Eintrag %d ist vom Typ %s, erwartet %s.', $value, $actual, $expected);
return sprintf('Eintrag %d ist kein Eintrag vom Typ %s.', $value, $expected);
}
}
@@ -38,6 +38,7 @@ final class FieldValidatorTest extends TestCase
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);
@@ -61,6 +62,7 @@ final class FieldValidatorTest extends TestCase
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',
@@ -75,7 +77,7 @@ final class FieldValidatorTest extends TestCase
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 vom Typ page, erwartet lit_person.', $errors['mentor']);
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);
@@ -84,11 +86,24 @@ final class FieldValidatorTest extends TestCase
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.', $errors['mentor']);
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
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