From d831cf59ca8b8b79655c6684199d8c7657c99a5f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 01:20:21 +0200 Subject: [PATCH] fix: never publish a missed schedule, refuse trashed posts, fail on meta read errors - PostGuard::editable() refuses trashed entries unless the caller opts in (content-get, revisions-list, post-trash); publish/schedule/unpublish/update/ restore/duplicate/replace now refuse them. - PostGuard::statusStable(): content-update, revision-restore and replace-with-copy refuse a future post whose date has passed (any save would publish it); the content-update rollback and PostCloner row writes re-check. - PostCloner::ownMeta() treats a failed SELECT as an error; replace() reads the copy before its first write. - replace-with-copy needs create_posts (backup clone) and reports backup_id in write_failed/partial_write. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Errors.php | 11 +- src/Abilities/WriteFailed.php | 8 +- .../WordPress/Content/ContentGetAbility.php | 4 +- .../Content/ContentUpdateAbility.php | 11 ++ src/Integrations/WordPress/PostGuard.php | 59 +++++++++- .../Revisions/RevisionRestoreAbility.php | 6 + .../Revisions/RevisionsListAbility.php | 4 +- .../WordPress/Status/PostCloner.php | 38 ++++++- .../WordPress/Status/PostDuplicateAbility.php | 9 -- .../Status/PostReplaceWithCopyAbility.php | 53 ++++++--- .../WordPress/Status/PostTrashAbility.php | 2 +- tests/Stubs/Wpdb.php | 1 + tests/Unit/Abilities/WriteFailedTest.php | 9 ++ .../Content/ContentGetAbilityTest.php | 13 +++ .../Content/ContentUpdateAbilityTest.php | 50 +++++++++ .../Integrations/WordPress/PostGuardTest.php | 38 +++++++ .../Revisions/RevisionRestoreAbilityTest.php | 39 +++++++ .../Revisions/RevisionsListAbilityTest.php | 8 ++ .../WordPress/Status/PostClonerTest.php | 106 +++++++++++++++++- .../Status/PostPublishAbilityTest.php | 10 ++ .../Status/PostReplaceWithCopyAbilityTest.php | 34 +++++- .../Status/PostScheduleAbilityTest.php | 10 ++ .../WordPress/Status/PostTrashAbilityTest.php | 12 ++ .../Status/PostUnpublishAbilityTest.php | 9 ++ 24 files changed, 501 insertions(+), 43 deletions(-) diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index 60d1884..b98e378 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -98,15 +98,20 @@ final class Errors return self::make('unsupported', $message, $hint, 400); } - /** A write failed and was rolled back completely. */ - public static function writeFailed(string $reason): WP_Error + /** + * A write failed and was rolled back completely. + * + * @param array $details Extra error details (e.g. a backup that stays). + */ + public static function writeFailed(string $reason, array $details = []): WP_Error { return self::make( 'write_failed', trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.', 'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator ' . 'informieren (Details im PHP-Error-Log der Website).', - 500 + 500, + $details ); } diff --git a/src/Abilities/WriteFailed.php b/src/Abilities/WriteFailed.php index 936a5da..a557dc5 100644 --- a/src/Abilities/WriteFailed.php +++ b/src/Abilities/WriteFailed.php @@ -13,12 +13,12 @@ use WP_Error; * written. getMessage() is the internal detail for the PHP error log; $reason is the * client-safe German text. The AbilityRegistrar logs the detail and returns toError(): * lit_write_failed when everything was rolled back, lit_partial_write otherwise. - * $state/$hint/$details customise the partial-write error for writes that are not about a - * post (see Errors::partialWrite()). + * $state/$hint customise the partial-write error for writes that are not about a post (see + * Errors::partialWrite()); $details are added to either error. */ final class WriteFailed extends RuntimeException { - /** @param array $details Extra error details for an incomplete rollback. */ + /** @param array $details Extra error details for the client. */ public function __construct( public readonly string $reason, public readonly bool $rolledBack, @@ -41,7 +41,7 @@ final class WriteFailed extends RuntimeException public function toError(): WP_Error { return $this->rolledBack - ? Errors::writeFailed($this->reason) + ? Errors::writeFailed($this->reason, $this->details) : Errors::partialWrite( $this->reason, $this->postId, diff --git a/src/Integrations/WordPress/Content/ContentGetAbility.php b/src/Integrations/WordPress/Content/ContentGetAbility.php index dae031f..d70db5b 100644 --- a/src/Integrations/WordPress/Content/ContentGetAbility.php +++ b/src/Integrations/WordPress/Content/ContentGetAbility.php @@ -59,14 +59,14 @@ final class ContentGetAbility implements Ability public function permission(array $input): bool|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); return $post instanceof WP_Error ? $post : true; } public function execute(array $input): mixed { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php index fe92ad2..3d439c2 100644 --- a/src/Integrations/WordPress/Content/ContentUpdateAbility.php +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -113,6 +113,11 @@ final class ContentUpdateAbility implements Ability return Errors::revMismatch(); } + $unstable = PostGuard::statusStable($post); + if ($unstable !== null) { + return $unstable; + } + if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) { return Errors::unsupported( 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.', @@ -279,6 +284,12 @@ final class ContentUpdateAbility implements Ability return; } + // Both restore paths save through wp_update_post(), which would publish a missed schedule. + $current = get_post((int) $post->ID); + if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) { + throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.'); + } + $fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED)); if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { $restored = wp_restore_post_revision($revisionId, $fromRevision); diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index d247543..3aa842e 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -16,8 +16,17 @@ use WP_Post; */ final class PostGuard { - public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error - { + /** + * Loads an entry the current user may edit. Trashed entries are refused (every write + * on them would be surprising: publishing, scheduling or updating a trashed entry); + * readers and lit/post-trash pass $allowTrashed. + */ + public static function editable( + mixed $postId, + ContentSchema $schema, + string $field = 'post_id', + bool $allowTrashed = false + ): WP_Post|WP_Error { $id = self::id($postId); if ($id === null) { return Errors::invalidInput( @@ -35,9 +44,55 @@ final class PostGuard return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id)); } + if (! $allowTrashed && $post->post_status === 'trash') { + return Errors::invalidInput( + sprintf('Eintrag %d liegt im Papierkorb.', $id), + [$field => 'Eintrag liegt im Papierkorb'], + 'Eintrag zuerst in WordPress wiederherstellen.' + ); + } + return $post; } + /** + * A scheduled entry whose publication time has passed (WordPress' cron missed it). + * wp_update_post() publishes such an entry on any save, so writes must not touch it. + */ + public static function missedSchedule(WP_Post $post): bool + { + $gmt = (string) $post->post_date_gmt; + if ($post->post_status !== 'future' || $gmt === '' || $gmt === '0000-00-00 00:00:00') { + return false; + } + + $time = strtotime($gmt . ' UTC'); + + return $time !== false && $time <= time(); + } + + /** + * Refuses a write that would change the status as a side effect (spec §2: status is only + * changed on explicit request) – currently a missed schedule, which any save would publish. + */ + public static function statusStable(WP_Post $post, string $field = 'post_id'): ?WP_Error + { + if (! self::missedSchedule($post)) { + return null; + } + + return Errors::invalidInput( + sprintf( + 'Eintrag %d ist geplant, der Veröffentlichungszeitpunkt ist aber schon verstrichen; jedes Speichern ' + . 'würde ihn sofort veröffentlichen. Nichts wurde gespeichert.', + (int) $post->ID + ), + [$field => 'geplanter Zeitpunkt verstrichen'], + 'Beim Nutzer nachfragen: jetzt veröffentlichen (lit-post-publish) oder neu planen (lit/post-schedule ' + . 'über mcp-adapter-execute-ability); danach die Änderung wiederholen.' + ); + } + /** * The brief declares this `: object|WP_Error`, but `object` already covers every * class type, so PHP refuses `object` inside a union ("Type object cannot be diff --git a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php index 4055bd7..c6b9c88 100644 --- a/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.php @@ -111,6 +111,12 @@ final class RevisionRestoreAbility implements Ability return Errors::revMismatch(); } + // wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule. + $unstable = PostGuard::statusStable($post, 'revision_id'); + if ($unstable !== null) { + return $unstable; + } + $backupId = RevisionPoint::capture($postId); $restored = wp_restore_post_revision((int) $revision->ID); if (! $restored) { diff --git a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php index d31f4bc..4bdd893 100644 --- a/src/Integrations/WordPress/Revisions/RevisionsListAbility.php +++ b/src/Integrations/WordPress/Revisions/RevisionsListAbility.php @@ -98,14 +98,14 @@ final class RevisionsListAbility implements Ability public function permission(array $input): bool|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); return $post instanceof WP_Error ? $post : true; } public function execute(array $input): mixed { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/src/Integrations/WordPress/Status/PostCloner.php b/src/Integrations/WordPress/Status/PostCloner.php index 6c62d7d..fdfa993 100644 --- a/src/Integrations/WordPress/Status/PostCloner.php +++ b/src/Integrations/WordPress/Status/PostCloner.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\WordPress\PostGuard; use RuntimeException; use Throwable; use WP_Error; @@ -79,7 +80,8 @@ class PostCloner /** * Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy - * into the original. Status, slug, date and author of the original stay. + * into the original. Status, slug, date and author of the original stay. Everything is + * read before the first write, so a failed read leaves the original untouched. * * @throws ReplaceFailed Naming the parts that were (possibly) written. */ @@ -88,14 +90,15 @@ class PostCloner $originalId = (int) $original->ID; $parts = []; try { + $meta = $this->ownMeta((int) $copy->ID); + $terms = $this->termIds($copy); + $this->assertNoMissedSchedule($originalId); $parts[] = 'post'; $this->updateRow($originalId, [ 'post_title' => self::withoutSuffix((string) $copy->post_title), 'post_content' => (string) $copy->post_content, 'post_excerpt' => (string) $copy->post_excerpt, ]); - $meta = $this->ownMeta((int) $copy->ID); - $terms = $this->termIds($copy); $parts[] = 'meta'; $this->replaceMeta($originalId, $meta); $parts[] = 'terms'; @@ -225,6 +228,7 @@ class PostCloner */ private function updateRow(int $postId, array $columns): void { + $this->assertNoMissedSchedule($postId); $result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true); if ($result instanceof WP_Error || ! $result) { $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; @@ -232,14 +236,33 @@ class PostCloner } } + /** + * wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row. + * + * @throws RuntimeException + */ + private function assertNoMissedSchedule(int $postId): void + { + $post = get_post($postId); + if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) { + throw new RuntimeException(sprintf( + 'Post %d not written: saving it now would publish a missed schedule.', + $postId + )); + } + } + /** * Stored meta of a post without SKIP_META: meta_value strings exactly as in the database. + * A failed query is an error – treating it as "no meta" would wipe meta on replace(). * * @return array> + * @throws RuntimeException */ private function ownMeta(int $postId): array { $db = $this->db(); + $db->last_error = ''; $rows = $db->get_results( $db->prepare( "SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id", @@ -247,9 +270,16 @@ class PostCloner ), 'ARRAY_A' ); + if (! is_array($rows) || (string) $db->last_error !== '') { + throw new RuntimeException(sprintf( + 'Meta of post %d could not be read: %s', + $postId, + (string) $db->last_error !== '' ? (string) $db->last_error : 'no result' + )); + } $meta = []; - foreach ((array) $rows as $row) { + foreach ($rows as $row) { $key = (string) $row['meta_key']; if (! in_array($key, self::SKIP_META, true)) { $meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value']; diff --git a/src/Integrations/WordPress/Status/PostDuplicateAbility.php b/src/Integrations/WordPress/Status/PostDuplicateAbility.php index b0988b5..704cadf 100644 --- a/src/Integrations/WordPress/Status/PostDuplicateAbility.php +++ b/src/Integrations/WordPress/Status/PostDuplicateAbility.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Status; use LIT\MCP\Abilities\Ability; -use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\ToolResult; @@ -126,14 +125,6 @@ final class PostDuplicateAbility implements Ability return $post; } - if ($post->post_status === 'trash') { - return Errors::invalidInput( - 'Der Eintrag liegt im Papierkorb.', - ['post_id' => 'Eintrag liegt im Papierkorb'], - 'Eintrag zuerst in WordPress wiederherstellen.' - ); - } - $type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts'); return $type instanceof WP_Error ? $type : $post; diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 0386f45..711e1f2 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -134,9 +134,9 @@ final class PostReplaceWithCopyAbility implements Ability try { $this->cloner->replace($original, $copy); } catch (ReplaceFailed $e) { - $this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e); + $this->rollBack($original, $snapshot, $e->parts, $revisionId, $backupId, $e->getPrevious() ?? $e); } catch (Throwable $e) { - $this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e); + $this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $backupId, $e); } return $this->result($original, $copy, $input, $revisionId, $backupId); @@ -169,14 +169,6 @@ final class PostReplaceWithCopyAbility implements Ability ); } - if ($original->post_status === 'trash') { - return Errors::invalidInput( - 'Das Original liegt im Papierkorb.', - ['original_id' => 'Original liegt im Papierkorb'], - 'Original zuerst in WordPress wiederherstellen.' - ); - } - if (! in_array($copy->post_status, ['draft', 'pending'], true)) { return Errors::invalidInput( sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status), @@ -189,6 +181,11 @@ final class PostReplaceWithCopyAbility implements Ability return Errors::revMismatch(); } + $unstable = PostGuard::statusStable($original, 'original_id'); + if ($unstable !== null) { + return $unstable; + } + if (! ($this->trashEnabled)()) { return Errors::unsupported( 'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. ' @@ -269,17 +266,20 @@ final class PostReplaceWithCopyAbility implements Ability * @param array{post: array, meta: array>, terms: array>} * $snapshot Output of PostCloner::snapshot(). * @param list $parts Parts replace() had (possibly) written. - * @throws WriteFailed Always – rolledBack tells whether the original was restored completely. + * @throws WriteFailed Always – rolledBack tells whether the original was restored completely; + * both name the backup (backup_id), which stays in the trash. */ private function rollBack( WP_Post $original, array $snapshot, array $parts, ?int $revisionId, + int $backupId, Throwable $cause ): never { $originalId = (int) $original->ID; $detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage()); + $details = ['backup_id' => $backupId]; try { $this->cloner->restore($original, $snapshot, $parts); } catch (Throwable $e) { @@ -296,11 +296,33 @@ final class PostReplaceWithCopyAbility implements Ability $originalId, $revisionId, $cause, - $detail . '; rollback incomplete: ' . $e->getMessage() + $detail . '; rollback incomplete: ' . $e->getMessage(), + sprintf( + 'Eintrag %d ist möglicherweise teilweise geändert worden (Stand vorher: Revision %s); die ' + . 'vollständige Sicherung des vorherigen Stands liegt als Eintrag %d im Papierkorb.', + $originalId, + $revisionId === null ? '–' : (string) $revisionId, + $backupId + ), + 'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt ' + . 'und Auszug lassen sich mit lit/revision-restore (revision_id) zurückholen, Felder/Meta und ' + . 'Begriffe stehen in der Sicherung backup_id (in WordPress aus dem Papierkorb holen). Dem ' + . 'Nutzer Bescheid geben.', + $details ); } - throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail); + throw new WriteFailed( + self::REASON, + true, + $originalId, + $revisionId, + $cause, + $detail, + null, + null, + $details + ); } /** @@ -323,6 +345,9 @@ final class PostReplaceWithCopyAbility implements Ability return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.'); } - return [$original, $copy]; + // The swap first clones the original into a backup entry of the same type. + $type = PostGuard::postType((string) $original->post_type, $this->schema, 'create_posts'); + + return $type instanceof WP_Error ? $type : [$original, $copy]; } } diff --git a/src/Integrations/WordPress/Status/PostTrashAbility.php b/src/Integrations/WordPress/Status/PostTrashAbility.php index 4933da6..4c91efb 100644 --- a/src/Integrations/WordPress/Status/PostTrashAbility.php +++ b/src/Integrations/WordPress/Status/PostTrashAbility.php @@ -144,7 +144,7 @@ final class PostTrashAbility implements Ability /** @param array $input */ private function load(array $input): WP_Post|WP_Error { - $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true); if ($post instanceof WP_Error) { return $post; } diff --git a/tests/Stubs/Wpdb.php b/tests/Stubs/Wpdb.php index 61c4338..541ca8c 100644 --- a/tests/Stubs/Wpdb.php +++ b/tests/Stubs/Wpdb.php @@ -16,6 +16,7 @@ if (! class_exists('wpdb')) { public $usermeta = 'wp_usermeta'; public $options = 'wp_options'; public $insert_id = 0; + public $last_error = ''; public function prepare($query, ...$args) { diff --git a/tests/Unit/Abilities/WriteFailedTest.php b/tests/Unit/Abilities/WriteFailedTest.php index f9a3fe5..d0728ab 100644 --- a/tests/Unit/Abilities/WriteFailedTest.php +++ b/tests/Unit/Abilities/WriteFailedTest.php @@ -46,4 +46,13 @@ final class WriteFailedTest extends TestCase self::assertSame('Mit lit-menu-get prüfen.', $error->get_error_data()['hint']); self::assertSame(3, $error->get_error_data()['menu_id']); } + + public function testCleanRollbackKeepsItsDetails(): void + { + $error = (new WriteFailed('Nicht übernommen.', true, details: ['backup_id' => 88]))->toError(); + + self::assertSame('lit_write_failed', $error->get_error_code()); + self::assertSame(88, $error->get_error_data()['backup_id']); + self::assertStringContainsString('"backup_id":88', $error->get_error_message()); + } } diff --git a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php index 7555a50..1f37160 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentGetAbilityTest.php @@ -29,6 +29,19 @@ final class ContentGetAbilityTest extends TestCase self::assertSame(['id' => 5], $ability->execute(['post_id' => 5])); } + public function testATrashedPostCanStillBeRead(): void + { + $post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $presenter = Mockery::mock(PostPresenter::class); + $presenter->expects('full')->with($post)->andReturn(['id' => 5]); + + self::assertSame(['id' => 5], (new ContentGetAbility($schema, $presenter))->execute(['post_id' => 5])); + } + public function testMissingPostIsNotFound(): void { Functions\when('get_post')->justReturn(null); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php index d8f0f09..2f8aed8 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -61,6 +61,32 @@ final class ContentUpdateAbilityTest extends TestCase ); } + public function testATrashedPostIsRefused(): void + { + $this->post->post_status = 'trash'; + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testAMissedScheduleIsRefusedBecauseSavingWouldPublish(): void + { + $this->post->post_status = 'future'; + $this->post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600); + Functions\expect('wp_update_post')->never(); + Functions\expect('wp_save_post_revision')->never(); + + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Neu']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('post_id', $result->get_error_data()['fields']); + self::assertStringContainsString('lit/post-schedule', $result->get_error_data()['hint']); + } + public function testStaleRevIsRejectedWithoutWriting(): void { Functions\expect('wp_update_post')->never(); @@ -254,6 +280,30 @@ final class ContentUpdateAbilityTest extends TestCase self::assertSame(['post_name' => 'team', 'ID' => 5], $updates[1]); } + public function testTheRollbackNeverRewritesARowWhoseScheduleHasPassedMeanwhile(): void + { + [$writer, $input] = $this->failingWriter(['post_name' => 'unser-team']); + $post = $this->post; + $updates = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates, $post): int { + $updates[] = $data; + // The entry turned into a missed schedule while the call ran (e.g. edited in WordPress). + $post->post_status = 'future'; + $post->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 5); + return 5; + }); + Functions\when('wp_revisions_enabled')->justReturn(true); + Functions\expect('wp_restore_post_revision')->never(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial'); + + $failure = $this->executeExpectingFailure($writer, $input, $log); + + self::assertFalse($failure->rolledBack); + self::assertCount(1, $updates); + self::assertStringContainsString('publish', $failure->getMessage()); + } + public function testAnIncompleteRollbackIsAPartialWriteAndLogged(): void { [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team'], false); diff --git a/tests/Unit/Integrations/WordPress/PostGuardTest.php b/tests/Unit/Integrations/WordPress/PostGuardTest.php index 5397f2b..a06d948 100644 --- a/tests/Unit/Integrations/WordPress/PostGuardTest.php +++ b/tests/Unit/Integrations/WordPress/PostGuardTest.php @@ -47,6 +47,44 @@ final class PostGuardTest extends TestCase self::assertSame('lit_forbidden', PostGuard::editable(5, $this->schema())->get_error_code()); } + public function testTrashedPostsAreRefusedUnlessAllowed(): void + { + $post = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash']); + Functions\when('get_post')->justReturn($post); + Functions\when('current_user_can')->justReturn(true); + + $error = PostGuard::editable(5, $this->schema(), 'copy_id'); + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('copy_id', $error->get_error_data()['fields']); + self::assertSame($post, PostGuard::editable(5, $this->schema(), 'post_id', true)); + } + + public function testMissedScheduleIsAFuturePostWhoseDateHasPassed(): void + { + $past = gmdate('Y-m-d H:i:s', time() - 3600); + $future = gmdate('Y-m-d H:i:s', time() + 3600); + + self::assertTrue(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $past]))); + self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'future', 'post_date_gmt' => $future]))); + self::assertFalse(PostGuard::missedSchedule(new WP_Post(['post_status' => 'draft', 'post_date_gmt' => $past]))); + self::assertFalse(PostGuard::missedSchedule( + new WP_Post(['post_status' => 'future', 'post_date_gmt' => '0000-00-00 00:00:00']) + )); + } + + public function testStatusStableRefusesAMissedScheduleWithAHint(): void + { + $post = new WP_Post(['ID' => 5, 'post_status' => 'future', 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60)]); + + $error = PostGuard::statusStable($post, 'original_id'); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('original_id', $error->get_error_data()['fields']); + self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); + self::assertStringContainsString('lit/post-schedule', $error->get_error_data()['hint']); + self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish']))); + } + public function testPostTypeChecksAvailabilityAndCapability(): void { $object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']]; diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php index d309937..97df141 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionRestoreAbilityTest.php @@ -76,6 +76,45 @@ final class RevisionRestoreAbilityTest extends TestCase self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); } + public function testATrashedEntryIsRefused(): void + { + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]), + 5 => new WP_Post(array_merge(self::CURRENT_POST, ['post_status' => 'trash'])), + default => null, + }); + Functions\expect('wp_restore_post_revision')->never(); + + $error = $this->ability(Mockery::mock(ChangeLog::class))->execute(['revision_id' => 810, 'rev' => 'x', 'confirm' => true]); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertArrayHasKey('revision_id', $error->get_error_data()['fields']); + } + + public function testAMissedScheduleIsRefusedBecauseRestoringWouldPublish(): void + { + $current = array_merge(self::CURRENT_POST, [ + 'post_status' => 'future', + 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 600), + ]); + Functions\when('get_post')->alias(static fn (int $id) => match ($id) { + 810 => new WP_Post(['ID' => 810, 'post_type' => 'revision', 'post_parent' => 5]), + 5 => new WP_Post($current), + default => null, + }); + Functions\expect('wp_save_post_revision')->never(); + Functions\expect('wp_restore_post_revision')->never(); + + $error = $this->ability(Mockery::mock(ChangeLog::class))->execute([ + 'revision_id' => 810, + 'rev' => Rev::ofPost(new WP_Post($current)), + 'confirm' => true, + ]); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + self::assertStringContainsString('lit-post-publish', $error->get_error_data()['hint']); + } + public function testMissingOrStaleRevIsRevMismatch(): void { Functions\expect('wp_restore_post_revision')->never(); diff --git a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php index 8aeb846..89636e3 100644 --- a/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Revisions/RevisionsListAbilityTest.php @@ -69,6 +69,14 @@ final class RevisionsListAbilityTest extends TestCase self::assertSame([PostPresenter::DIVI_HINT], $result['hints']); } + public function testRevisionsOfATrashedPostCanStillBeListed(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'trash'])); + Functions\when('wp_get_post_revisions')->justReturn([]); + + self::assertSame([], $this->ability()->execute(['post_id' => 5])['items']); + } + public function testInvalidCursorIsRejected(): void { $result = $this->ability()->execute(['post_id' => 5, 'cursor' => 'not-a-cursor']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php index 491ce4a..f2c58c0 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostClonerTest.php @@ -24,17 +24,30 @@ final class PostClonerTest extends TestCase private bool $insertFails = false; + /** Post IDs whose meta SELECT fails: 'null' returns null, 'error' sets $wpdb->last_error. */ + private array $metaReadFails = []; + protected function setUp(): void { parent::setUp(); Functions\when('wp_cache_delete')->justReturn(true); + Functions\when('get_post')->justReturn(null); } private function cloner(): PostCloner { $db = Mockery::mock(\wpdb::class)->makePartial(); - $db->allows('get_results')->andReturnUsing(function (string $sql): array { + $db->allows('get_results')->andReturnUsing(function (string $sql) use ($db): ?array { preg_match('/post_id = (\d+)/', $sql, $match); + $db->last_error = ''; + $failure = $this->metaReadFails[(int) $match[1]] ?? null; + if ($failure === 'null') { + return null; + } + if ($failure === 'error') { + $db->last_error = 'Table wp_postmeta is marked as crashed'; + return []; + } $rows = []; foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) { foreach ($values as $value) { @@ -246,6 +259,7 @@ final class PostClonerTest extends TestCase public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void { + Functions\when('get_object_taxonomies')->justReturn([]); Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); Functions\expect('delete_post_meta')->never(); @@ -272,6 +286,96 @@ final class PostClonerTest extends TestCase } } + /** @return array */ + public function metaReadFailures(): array + { + return ['null result' => ['null'], 'database error' => ['error']]; + } + + /** @dataProvider metaReadFailures */ + public function testAFailedMetaReadIsAnErrorNotEmptyMeta(string $failure): void + { + $this->metaReadFails = [5 => $failure]; + Functions\when('get_object_taxonomies')->justReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Meta of post 5 could not be read'); + + $this->cloner()->snapshot($this->original()); + } + + /** @dataProvider metaReadFailures */ + public function testReplaceReadsTheCopyBeforeWritingAnything(string $failure): void + { + $this->metaReadFails = [77 => $failure]; + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\expect('wp_update_post')->never(); + Functions\expect('delete_post_meta')->never(); + + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame([], $e->parts); + self::assertStringContainsString('Meta of post 77 could not be read', $e->getMessage()); + } + } + + public function testADuplicateWithAFailedMetaReadIsDeletedAgain(): void + { + $this->metaReadFails = [5 => 'error']; + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(90); + Functions\expect('wp_delete_post')->once()->with(90, true)->andReturn(new WP_Post(['ID' => 90])); + + try { + $this->cloner()->duplicate($this->original()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + } + + private function missedSchedule(): void + { + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 5, + 'post_type' => 'page', + 'post_status' => 'future', + 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60), + ])); + } + + public function testReplaceRefusesAMissedScheduleBeforeWriting(): void + { + $this->missedSchedule(); + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\expect('wp_update_post')->never(); + + try { + $this->cloner()->replace($this->original(), $this->copy()); + self::fail('ReplaceFailed expected'); + } catch (ReplaceFailed $e) { + self::assertSame([], $e->parts); + self::assertStringContainsString('missed schedule', $e->getMessage()); + } + } + + public function testRestoreNeverRewritesTheRowOfAMissedSchedule(): void + { + $this->missedSchedule(); + Functions\expect('wp_update_post')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('missed schedule'); + + $this->cloner()->restore($this->original(), [ + 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], + 'meta' => [], + 'terms' => [], + ], ['post']); + } + public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void { $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]]; diff --git a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php index 54a1884..28d4efb 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostPublishAbilityTest.php @@ -74,6 +74,16 @@ final class PostPublishAbilityTest extends TestCase self::assertStringContainsString('bereits veröffentlicht', $result['summary']); } + public function testATrashedPostIsRefused(): void + { + $this->stubPost(['post_status' => 'trash']); + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testPermissionNeedsPublishRights(): void { $this->stubPost(['post_status' => 'draft']); diff --git a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php index a6bc78c..6095927 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostReplaceWithCopyAbilityTest.php @@ -31,6 +31,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); Functions\when('get_post_meta')->justReturn('5'); Functions\when('get_permalink')->justReturn('https://kunde.de/team/'); Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); @@ -105,6 +106,20 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertSame('lit_validation_failed', $result->get_error_code()); } + public function testAMissedScheduleOfTheOriginalIsRefused(): void + { + $this->original->post_status = 'future'; + $this->original->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('backup')->never(); + $cloner->expects('replace')->never(); + + $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertArrayHasKey('original_id', $result->get_error_data()['fields']); + } + public function testAPendingCopyIsAccepted(): void { $this->copy->post_status = 'pending'; @@ -215,6 +230,7 @@ final class PostReplaceWithCopyAbilityTest extends TestCase } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + self::assertSame(88, $e->toError()->get_error_data()['backup_id']); } } @@ -239,10 +255,26 @@ final class PostReplaceWithCopyAbilityTest extends TestCase self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertSame(900, $e->revisionId); - self::assertSame('lit_partial_write', $e->toError()->get_error_code()); + $error = $e->toError(); + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertSame(88, $error->get_error_data()['backup_id']); + self::assertStringContainsString('Sicherung', $error->get_error_message()); + self::assertStringContainsString('88', $error->get_error_message()); } } + public function testNeedsTheRightToCreateEntriesForTheBackup(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + $cloner = Mockery::mock(PostCloner::class); + $cloner->expects('backup')->never(); + $cloner->expects('replace')->never(); + $ability = $this->ability($cloner, Mockery::mock(ChangeLog::class)); + + self::assertSame('lit_forbidden', $ability->permission($this->input())->get_error_code()); + self::assertSame('lit_forbidden', $ability->execute($this->input())->get_error_code()); + } + public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void { $cloner = Mockery::mock(PostCloner::class); diff --git a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php index 8bc6214..470eb11 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostScheduleAbilityTest.php @@ -52,6 +52,16 @@ final class PostScheduleAbilityTest extends TestCase self::assertStringContainsString('2099-06-01 09:30', $result['summary']); } + public function testATrashedPostIsRefused(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'trash'])); + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class))->execute(['post_id' => 5, 'date' => '2099-06-01 09:30']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testPastAndImpossibleDatesAreRejected(): void { Functions\expect('wp_update_post')->never(); diff --git a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php index e19e9ea..c8029d6 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostTrashAbilityTest.php @@ -60,6 +60,18 @@ final class PostTrashAbilityTest extends TestCase self::assertSame('lit_unsupported', $result->get_error_code()); } + public function testAnAlreadyTrashedPostIsANoOp(): void + { + $ability = $this->ability(Mockery::mock(ChangeLog::class)); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Alt', 'post_status' => 'trash'])); + Functions\expect('wp_trash_post')->never(); + + $result = $ability->execute(['post_id' => 5, 'confirm' => true]); + + self::assertSame([], $result['changed']); + self::assertStringContainsString('bereits im Papierkorb', $result['summary']); + } + public function testIsDestructiveAndIndirect(): void { $ability = $this->ability(Mockery::mock(ChangeLog::class)); diff --git a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php index 955d6c4..9a0954a 100644 --- a/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php +++ b/tests/Unit/Integrations/WordPress/Status/PostUnpublishAbilityTest.php @@ -37,6 +37,15 @@ final class PostUnpublishAbilityTest extends TestCase self::assertSame('unpublished', $result['changed'][0]['action']); } + public function testATrashedPostIsRefusedInsteadOfBecomingADraft(): void + { + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(ChangeLog::class), 'trash')->execute(['post_id' => 5]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + public function testDraftIsANoOp(): void { Functions\expect('wp_update_post')->never();