fix: check WordPress meta capabilities per field key

Fields from the meta/sample sources need edit_post_meta (update, checked in
PostWriter::prepare) or add_post_meta (create, checked right after the insert;
a refusal deletes the new draft again) for their key; a refusal is a
fields.<key> validation error.

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:20 +02:00
co-authored by Claude Opus 5.5
parent 563d9e8d15
commit 106a099483
4 changed files with 160 additions and 0 deletions
@@ -39,6 +39,7 @@ final class ContentCreateAbilityTest extends TestCase
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => $warnings]);
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
$writer->allows('deniedMeta')->andReturn([]);
Functions\when('get_current_user_id')->justReturn(3);
return $writer;
@@ -85,6 +86,7 @@ final class ContentCreateAbilityTest extends TestCase
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared);
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
$writer->expects('deniedMeta')->with(42, 'lit_person', ['role' => 'Leitung'], 'add_post_meta')->andReturn([]);
$writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input);
Functions\when('get_current_user_id')->justReturn(3);
Functions\expect('wp_insert_post')->once()->with([
@@ -126,6 +128,47 @@ final class ContentCreateAbilityTest extends TestCase
self::assertSame('lit_write_failed', $failure->toError()->get_error_code());
}
public function testMetaFieldsTheUserMayNotAddAreRefusedAndTheDraftIsRemoved(): void
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]);
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
$writer->expects('deniedMeta')->with(42, 'lit_person', ['city' => 'Trier'], 'add_post_meta')
->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']);
$writer->expects('writeExtras')->never();
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
$log = Mockery::mock(ChangeLog::class);
$log->expects('record')->never();
$result = (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT);
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertArrayHasKey('fields.city', $result->get_error_data()['fields']);
}
public function testARefusedDraftThatCannotBeDeletedIsAPartialWrite(): void
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]);
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
$writer->allows('deniedMeta')->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('wp_insert_post')->justReturn(42);
Functions\when('wp_delete_post')->justReturn(false);
$log = Mockery::mock(ChangeLog::class);
$log->expects('recordPartial');
try {
(new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(42, $e->postId);
}
}
public function testADraftThatCannotBeDeletedIsAPartialWriteAndLogged(): void
{
$writer = $this->writer();
@@ -131,6 +131,49 @@ final class PostWriterTest extends TestCase
self::assertCount(1, $result['warnings']);
}
public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
$checked = [];
Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool {
$checked[] = array_merge([$cap], $args);
return $args !== [5, 'secret'];
});
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare(
'page',
['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']],
false
);
self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']);
self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked);
}
public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true));
self::assertSame(
['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'],
$writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta')
);
}
public function testPostDataMapsOnlyGivenKeys(): void
{
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());