From e8f69f67ea026674644eadd05a5716779ed70d70 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 04:12:38 +0200 Subject: [PATCH] fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlobalUsages runs one query per request (ID, status, type – no content), checks read_post/edit_post in chunks of 200 with a primed and released post cache, lists at most 50 readable posts and refuses a confirmed global edit above 2000 live references (unsupported, edit in the Divi builder). Posts the caller may not read appear only as hidden_count / blocked_hidden_count; blocked_ids names readable posts only. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guides/divi-library.md | 8 +- src/Integrations/Divi/Library/GlobalEdits.php | 129 ++++++++------- .../Divi/Library/GlobalUsages.php | 152 +++++++++++++----- .../Divi/Library/LibraryListAbility.php | 5 +- src/Integrations/Divi/Library/UsageReport.php | 61 +++++++ .../Divi/Pages/DiviReplaceTextAbility.php | 2 +- .../Divi/Pages/DiviWriteAbility.php | 1 + tests/Support/LibraryFixtures.php | 24 +++ .../Divi/Library/GlobalEditsTest.php | 80 +++++++-- .../Divi/Library/GlobalUsagesTest.php | 137 ++++++++++++---- .../Divi/Library/LibraryListAbilityTest.php | 8 +- .../Divi/Pages/DiviNodeCopyAbilityTest.php | 5 +- 12 files changed, 445 insertions(+), 167 deletions(-) create mode 100644 src/Integrations/Divi/Library/UsageReport.php diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 35fa486..cec9b81 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -43,8 +43,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen. 2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden. 3. Ohne `confirm` ist jede Änderung eines globalen Eintrags ein Probelauf: Antwort `confirm_required` mit - `affected` (betroffene Seiten; Titel nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; - gespeichert wird nichts. + `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das + Konto nicht lesen darf – sie werden nur gezählt, ohne ID); gespeichert wird nichts. 4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`). 5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine @@ -53,7 +53,9 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv Die Bestätigung wird abgelehnt (`forbidden`), wenn das Konto einen veröffentlichten, privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf – schon der Probelauf warnt davor und nennt diese IDs in -`blocked_ids` (Entwürfe blockieren nicht). Sind Revisionen für die Bibliothek abgeschaltet, lehnt das +`blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht). Zeigen +mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab +(`unsupported`); solche Einträge im Divi-Builder bearbeiten. Sind Revisionen für die Bibliothek abgeschaltet, lehnt das Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein `unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei). diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index 20b1739..a3eee20 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -28,7 +28,9 @@ defined('ABSPATH') || exit; * copy is made first – the revision itself is DiviSaver's restore point, as for every page. * Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may * not edit_post one of the published, private or scheduled posts that show the entry (rulings - * P8/P8a, stricter than Divi); the dry run names them already (blocked_ids). + * P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may + * not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above + * GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead). * The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for * users without unfiltered_html, and when the trash is disabled (ruling P9, S37). */ @@ -89,27 +91,26 @@ final class GlobalEdits return ['scope' => SaveScope::LibraryItem, 'details' => []]; } - $usages = $this->usages->of($id); - $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']]; - $blocked = $this->blocked($id); + $report = $this->usages->report($id); if (($input['confirm'] ?? false) !== true) { return Errors::confirmRequired( sprintf( 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', $id, - $usages['count'], - self::pages($usages['count']) - ) . self::blockedNote($blocked), - $details + self::blockedDetails($blocked) + $report->count, + self::pages($report->count) + ) . self::hiddenNote($report) . self::blockedNote($report), + $report->details() ); } - $refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item); + $refusal = $this->pagesRefusal($id, $report) ?? $this->backupRefusal($item); if ($refusal !== null) { return $refusal; } + $details = $report->listing(); if (! wp_revisions_enabled($item)) { $details['backup_id'] = ($this->backup)($item); } @@ -127,9 +128,7 @@ final class GlobalEdits return $error; } - $id = (int) $page->post->ID; - $usages = $this->usages->of($id); - $blocked = $this->blocked($id); + $report = $this->usages->report((int) $page->post->ID); $data = $error->get_error_data(); $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; @@ -137,79 +136,93 @@ final class GlobalEdits Errors::clientMessage($error) . sprintf( ' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).', - $usages['count'], - self::pages($usages['count']) + $report->count, + self::pages($report->count) ) - . self::blockedNote($blocked), - array_merge( - $details, - ['affected' => $usages['items'], 'affected_count' => $usages['count']], - self::blockedDetails($blocked) - ) + . self::hiddenNote($report) + . self::blockedNote($report), + array_merge($details, $report->details()) ); } /** - * Ruling P8a: the live posts (published, private, scheduled – they render now or will) that show - * entry $id and that the user may not edit_post. Drafts and pending posts never block. - * - * @return list + * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above + * GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1). */ - private function blocked(int $id): array + private function pagesRefusal(int $id, UsageReport $report): ?WP_Error { - $live = $this->usages->liveIds($id); - if ($live === []) { - return []; + if ($report->tooMany) { + return Errors::unsupported( + sprintf( + 'Globaler Bibliothekseintrag %d erscheint auf %d veröffentlichten, privaten oder geplanten ' + . 'Einträgen – mehr als %d; so große Änderungen nimmt LIT MCP nicht vor. Nichts wurde ' + . 'gespeichert.', + $id, + $report->live, + GlobalUsages::HARD_CAP + ), + 'Den Eintrag im Divi-Builder bearbeiten.' + ); } - _prime_post_caches($live, false, false); - - return array_values(array_filter( - $live, - static fn (int $postId): bool => ! current_user_can('edit_post', $postId) - )); - } - - /** - * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id. - * - * @param list $blocked - */ - private function pagesRefusal(int $id, array $blocked): ?WP_Error - { - if ($blocked === []) { + if (! $report->blocks()) { return null; } return Errors::forbidden( sprintf( 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, ' - . 'die dieses Konto nicht bearbeiten darf (IDs %s); nichts wurde gespeichert.', + . 'die dieses Konto nicht bearbeiten darf (%s); nichts wurde gespeichert.', $id, - self::listed($blocked) + self::blockedList($report) ), 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' . 'oder Administrator).' ); } - /** @param list $blocked */ - private static function blockedNote(array $blocked): string + /** Audit L4: posts the caller may not read appear only as a count. */ + private static function hiddenNote(UsageReport $report): string { - return $blocked === [] ? '' : sprintf( - ' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' - . 'Konto nicht bearbeiten darf, zeigen den Eintrag (IDs %s, siehe blocked_ids).', - self::listed($blocked) + return $report->hidden === 0 ? '' : sprintf( + ' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).', + $report->hidden ); } - /** - * @param list $blocked - * @return array> - */ - private static function blockedDetails(array $blocked): array + private static function blockedNote(UsageReport $report): string { - return $blocked === [] ? [] : ['blocked_ids' => $blocked]; + if ($report->tooMany) { + return sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante ' + . 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.', + GlobalUsages::HARD_CAP + ); + } + + return ! $report->blocks() ? '' : sprintf( + ' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' + . 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).', + self::blockedList($report) + ); + } + + /** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */ + private static function blockedList(UsageReport $report): string + { + $parts = []; + if ($report->blocked !== []) { + $parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids'; + } + if ($report->blockedHidden > 0) { + $parts[] = sprintf( + '%d %s, die dieses Konto nicht lesen darf', + $report->blockedHidden, + $report->blockedHidden === 1 ? 'Eintrag' : 'Einträge' + ); + } + + return implode('; ', $parts); } /** @param list $ids At most LISTED_IDS of them, then "und N weitere". */ diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index 12342ff..7f385ae 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -4,87 +4,151 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use WP_Post; + defined('ABSPATH') || exit; /** - * Pages (and Theme Builder layouts) that show global library entry $id: every living post whose - * content has "globalModule":"" – Divi's own way to find instances (string scan, - * BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it. + * Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every + * living post whose content has "globalModule":"" – Divi's own way to find instances (string + * scan, BlockParserStore, F10). One query per report (ID, status and type only – never the content, + * audit L1); the rights of the caller are then checked in chunks with a bounded post cache: + * - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4); + * - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when + * readable, else as a count; above HARD_CAP live posts the edit is refused as a whole. + * Not final so tests can mock rows(). */ class GlobalUsages { + /** At most this many readable posts are listed. */ public const LIMIT = 50; + /** Above this many live posts a confirmed edit is refused without checking each of them (audit L1). */ + public const HARD_CAP = 2000; + + /** At most this many posts are checked for read_post (the listing and hidden_count). */ + public const CHECK_CAP = 2000; + + /** Posts whose rights are checked with one primed cache. */ + public const CHUNK = 200; + + /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ + public const LIVE = ['publish', 'private', 'future']; + /** Every post that exists for the user: listed in the dry run. */ private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')"; - /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ - private const LIVE = "post_status IN ('publish', 'private', 'future')"; - public function __construct(private ?\wpdb $db = null) { } - /** @return array{items: list, count: int} */ - public function of(int $libraryId): array + /** + * The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post + * on every live post) – the dry run and the confirm of an edit need it, a listing does not. + */ + public function report(int $libraryId, bool $withBlocked = true): UsageReport + { + $rows = $this->rows($libraryId); + $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); + $tooMany = $withBlocked && $live > self::HARD_CAP; + $checkEdit = $withBlocked && ! $tooMany; + + $items = []; + $hidden = 0; + $blocked = []; + $blockedHidden = 0; + $checked = 0; + foreach (array_chunk($rows, self::CHUNK) as $chunk) { + $todo = array_values(array_filter( + $chunk, + static function (array $row) use (&$checked, $checkEdit): bool { + $isLive = in_array($row['status'], self::LIVE, true); + if ($checked < self::CHECK_CAP || ($checkEdit && $isLive)) { + ++$checked; + + return true; + } + + return false; + } + )); + if ($todo === []) { + continue; + } + + $ids = array_map(static fn (array $row): int => $row['id'], $todo); + $fresh = _get_non_cached_ids($ids, 'posts'); + _prime_post_caches($ids, false, false); + foreach ($todo as $row) { + $id = $row['id']; + $readable = current_user_can('read_post', $id); + if ($readable && count($items) < self::LIMIT) { + $post = get_post($id); + $items[] = [ + 'post_id' => $id, + 'title' => $post instanceof WP_Post ? (string) $post->post_title : '', + 'status' => $row['status'], + 'type' => $row['type'], + ]; + } elseif (! $readable) { + ++$hidden; + } + + if ($checkEdit && in_array($row['status'], self::LIVE, true) && ! $this->editable($row)) { + if ($readable) { + $blocked[] = $id; + } else { + ++$blockedHidden; + } + } + } + foreach ($fresh as $id) { + wp_cache_delete((int) $id, 'posts'); + } + } + + return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany); + } + + /** + * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. + * + * @return list + */ + public function rows(int $libraryId): array { $db = $this->db(); - $like = self::like($db, $libraryId); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery - $count = (int) $db->get_var( - $db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::where(self::LISTED), $like) - ); $rows = $db->get_results( $db->prepare( - 'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' - . self::where(self::LISTED) . ' ORDER BY ID ASC LIMIT %d', - $like, - self::LIMIT + 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND ' + . "post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', + self::like($db, $libraryId) ), 'ARRAY_A' ); // phpcs:enable - $items = []; + $result = []; foreach (is_array($rows) ? $rows : [] as $row) { - $id = (int) $row['ID']; - $items[] = [ - 'post_id' => $id, - 'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)', + $result[] = [ + 'id' => (int) $row['ID'], 'status' => (string) $row['post_status'], 'type' => (string) $row['post_type'], ]; } - return ['items' => $items, 'count' => max($count, count($items))]; + return $result; } /** - * IDs of every live post (published, private, scheduled) that shows $libraryId – no limit - * (ruling P8a checks edit_post on each of them before a confirmed global edit). + * Ruling P8a: whether the caller may edit_post the live post $row. * - * @return list + * @param array{id: int, status: string, type: string} $row */ - public function liveIds(int $libraryId): array + protected function editable(array $row): bool { - $db = $this->db(); - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery - $ids = $db->get_col($db->prepare( - 'SELECT ID FROM ' . $db->posts . ' WHERE ' . self::where(self::LIVE) . ' ORDER BY ID ASC', - self::like($db, $libraryId) - )); - // phpcs:enable - - return array_values(array_map('intval', is_array($ids) ? $ids : [])); - } - - /** - * The one condition of both queries: posts (no revisions) whose content holds an instance - * (placeholder %s = like()), with the fixed status clause $status (a class constant). - */ - private static function where(string $status): string - { - return "post_content LIKE %s AND post_type <> 'revision' AND " . $status; + return current_user_can('edit_post', $row['id']); } private static function like(\wpdb $db, int $libraryId): string diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index b503d27..85a0c3b 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -103,6 +103,7 @@ final class LibraryListAbility implements Ability 'edit_url' => ['type' => 'string'], 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], ], 'required' => ['items', 'next_cursor'], ]; @@ -232,9 +233,7 @@ final class LibraryListAbility implements Ability 'edit_url' => $page->editUrl(), ]; if ($item['scope'] === 'global') { - $usages = $this->usages->of((int) $page->post->ID); - $result['affected'] = $usages['items']; - $result['affected_count'] = $usages['count']; + $result += $this->usages->report((int) $page->post->ID, false)->listing(); } return $result; diff --git a/src/Integrations/Divi/Library/UsageReport.php b/src/Integrations/Divi/Library/UsageReport.php new file mode 100644 index 0000000..d04e6b6 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageReport.php @@ -0,0 +1,61 @@ + $items Readable + * posts (at most GlobalUsages::LIMIT). + * @param int $hidden Posts the caller may not read. + * @param int $live Published, private and scheduled posts among them (ruling P8a). + * @param list $blocked Readable live posts the caller may not edit. + * @param int $blockedHidden Unreadable live posts the caller may not edit. + * @param bool $tooMany More live posts than GlobalUsages::HARD_CAP – not checked one by one (audit L1). + */ + public function __construct( + public readonly int $count, + public readonly array $items, + public readonly int $hidden, + public readonly int $live, + public readonly array $blocked, + public readonly int $blockedHidden, + public readonly bool $tooMany + ) { + } + + public function exists(): bool + { + return $this->count > 0; + } + + /** Whether a confirmed edit would be refused (ruling P8a, audit L1). */ + public function blocks(): bool + { + return $this->tooMany || $this->blocked !== [] || $this->blockedHidden > 0; + } + + /** @return array affected, affected_count and, when there are any, hidden_count. */ + public function listing(): array + { + return ['affected' => $this->items, 'affected_count' => $this->count] + + ($this->hidden > 0 ? ['hidden_count' => $this->hidden] : []); + } + + /** @return array listing() plus blocked_ids and blocked_hidden_count when there are any. */ + public function details(): array + { + return $this->listing() + + ($this->blocked !== [] ? ['blocked_ids' => $this->blocked] : []) + + ($this->blockedHidden > 0 ? ['blocked_hidden_count' => $this->blockedHidden] : []); + } +} diff --git a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php index 7a8ce54..7b3e433 100644 --- a/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php +++ b/src/Integrations/Divi/Pages/DiviReplaceTextAbility.php @@ -404,7 +404,7 @@ final class DiviReplaceTextAbility implements Ability . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', 'affected' => $data['affected'] ?? [], 'affected_count' => $data['affected_count'] ?? 0, - ]; + ] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []); } /** diff --git a/src/Integrations/Divi/Pages/DiviWriteAbility.php b/src/Integrations/Divi/Pages/DiviWriteAbility.php index fac7ac2..3bddc36 100644 --- a/src/Integrations/Divi/Pages/DiviWriteAbility.php +++ b/src/Integrations/Divi/Pages/DiviWriteAbility.php @@ -39,6 +39,7 @@ abstract class DiviWriteAbility implements Ability // made when revisions are off. 'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected_count' => ['type' => 'integer'], + 'hidden_count' => ['type' => 'integer'], 'backup_id' => ['type' => 'integer'], ], ['tree_rev']); } diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php index df9fd56..83742fb 100644 --- a/tests/Support/LibraryFixtures.php +++ b/tests/Support/LibraryFixtures.php @@ -8,6 +8,7 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; +use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use Mockery; @@ -111,6 +112,29 @@ final class LibraryFixtures return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); } + /** + * GlobalUsages whose query answers $rows (a closure is asked on every call); the rights checks + * are the real ones, with the post-cache functions stubbed. + * + * @param list|\Closure(): list $rows + */ + public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages + { + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + $usages = Mockery::mock(GlobalUsages::class, [null, $roles])->makePartial(); + $usages->allows('rows')->andReturnUsing(static fn (): array => is_array($rows) ? $rows : $rows()); + + return $usages; + } + + /** @return list One row per ID. */ + public static function rows(array $ids, string $status = 'publish', string $type = 'page'): array + { + return array_map(static fn (int $id): array => ['id' => $id, 'status' => $status, 'type' => $type], $ids); + } + public static function loader(?LibraryAccess $access = null): DiviPageLoader { return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php index cf171d2..2d2ba12 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -50,19 +50,16 @@ final class GlobalEditsTest extends TestCase Functions\when('_prime_post_caches')->justReturn(null); } + /** The live posts ($this->live; 1053 published, the others private) and draft 1070. */ private function usages(): GlobalUsages { - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn([ - 'items' => [ - ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], - ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], - ], - 'count' => 2, - ]); - $usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live); - - return $usages; + return LibraryFixtures::usages(fn (): array => array_merge( + array_map( + static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'], + $this->live + ), + [['id' => 1070, 'status' => 'draft', 'type' => 'page']] + )); } /** @param list $deniedRoles */ @@ -274,6 +271,60 @@ final class GlobalEditsTest extends TestCase self::assertStringContainsString('abgelehnt', $dryRun->get_error_message()); } + /** Audit L4: posts the caller may not read appear only as counts – no ID, status or type. */ + public function testUnreadablePostsAppearOnlyAsCounts(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $this->live = [1053, 1081]; + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! (in_array($cap, ['read_post', 'edit_post'], true) + && in_array((int) ($args[0] ?? 0), [1070, 1081], true)) + ); + + $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059)); + $refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true)); + + self::assertToolError($dryRun, 'confirm_required'); + $data = $dryRun->get_error_data(); + self::assertSame([1053], array_column($data['affected'], 'post_id')); + self::assertSame(3, $data['affected_count']); + self::assertSame(2, $data['hidden_count']); + self::assertSame(1, $data['blocked_hidden_count']); + self::assertArrayNotHasKey('blocked_ids', $data); + self::assertStringNotContainsString('1081', $dryRun->get_error_message()); + self::assertStringNotContainsString('1070', $dryRun->get_error_message()); + self::assertStringNotContainsString('1081', (string) wp_json_encode($data)); + self::assertToolError($refused, 'forbidden'); + self::assertStringNotContainsString('1081', $refused->get_error_message()); + self::assertStringContainsString('nicht lesen darf', $refused->get_error_message()); + self::assertSame([], $this->saved); + } + + /** Audit L1: above the hard cap a confirmed global edit is refused without checking each post. */ + public function testAboveTheHardCapTheConfirmIsRefused(): void + { + LibraryFixtures::given([LibraryFixtures::item(1059)]); + $this->live = range(20001, 20001 + GlobalUsages::HARD_CAP); + $edits = 0; + Functions\when('current_user_can')->alias(static function (string $cap, mixed ...$args) use (&$edits): bool { + $edits += $cap === 'edit_post' && ($args[0] ?? 0) !== 1059 ? 1 : 0; + + return true; + }); + + $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059)); + $refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true)); + + self::assertToolError($dryRun, 'confirm_required'); + self::assertStringContainsString('mehr als 2000', $dryRun->get_error_message()); + self::assertCount(GlobalUsages::LIMIT, $dryRun->get_error_data()['affected']); + self::assertSame(GlobalUsages::HARD_CAP + 2, $dryRun->get_error_data()['affected_count']); + self::assertToolError($refused, 'unsupported'); + self::assertStringContainsString('Divi-Builder', $refused->get_error_data()['hint']); + self::assertSame(0, $edits, 'no edit_post check per post above the cap'); + self::assertSame([], $this->saved); + } + public function testADryRunWithoutBlockersHasNoBlockedIds(): void { LibraryFixtures::given([LibraryFixtures::item(1059)]); @@ -329,12 +380,7 @@ final class GlobalEditsTest extends TestCase public function testDeleteNoticeUsesTheSingularForOnePage(): void { LibraryFixtures::given([LibraryFixtures::item(1058)]); - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn([ - 'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']], - 'count' => 1, - ]); - $usages->allows('liveIds')->andReturn([1053]); + $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); $access = LibraryFixtures::access(); $design = DiviWriterFixture::designReader(); $writer = new DiviWriter( diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 0436fb8..5e17674 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -6,60 +6,133 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; +use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WP_Post; final class GlobalUsagesTest extends TestCase { - public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void + /** Audit L1: one query, ID, status and type only – never the content, no COUNT, no second scan. */ + public function testFindsInstancesLikeDiviWithOneQueryWithoutContent(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $queries = []; - $db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string { - $queries[] = $sql; - - return '3'; - }); $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { $queries[] = $sql; return [ - ['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'], - ['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'], + ['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'], + ['ID' => '1061', 'post_status' => 'private', 'post_type' => 'et_header_layout'], ]; }); - Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061); - $usages = (new GlobalUsages($db))->of(1058); + $rows = (new GlobalUsages($db))->rows(1058); - self::assertSame(3, $usages['count']); self::assertSame( [ - ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'], - ['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'], + ['id' => 1053, 'status' => 'draft', 'type' => 'page'], + ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], ], - $usages['items'] + $rows ); + self::assertCount(1, $queries); + self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]); self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]); - self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]); - self::assertStringContainsString('LIMIT 50', $queries[1]); - } - - /** Ruling P8a: every live (published, private, scheduled) post showing the entry, beyond the listing limit. */ - public function testLiveIdsAreUnlimited(): void - { - $db = Mockery::mock(\wpdb::class)->makePartial(); - $queries = []; - $db->allows('get_col')->andReturnUsing(static function (string $sql) use (&$queries): array { - $queries[] = $sql; - - return ['1053', '1080']; - }); - - self::assertSame([1053, 1080], (new GlobalUsages($db))->liveIds(1059)); - self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]); - self::assertStringContainsString("post_status IN ('publish', 'private', 'future')", $queries[0]); + self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]); + self::assertStringNotContainsString('post_content,', $queries[0]); + } + + /** Audit L4: readable posts with title, unreadable ones only counted. */ + public function testListsReadablePostsAndCountsTheOthers(): void + { + $usages = LibraryFixtures::usages([ + ['id' => 1053, 'status' => 'draft', 'type' => 'page'], + ['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'], + ]); + Functions\when('get_post')->alias( + static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_title' => 'Titel ' . $id]) + ); + Functions\when('current_user_can')->alias( + static fn (string $cap, int $id): bool => $id !== 1061 + ); + + $report = $usages->report(1058); + + self::assertSame(2, $report->count); + self::assertSame( + [['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'draft', 'type' => 'page']], + $report->items + ); + self::assertSame(1, $report->hidden); + self::assertSame(1, $report->live); + self::assertSame([], $report->blocked); + self::assertSame(1, $report->blockedHidden); + self::assertSame( + ['affected' => $report->items, 'affected_count' => 2, 'hidden_count' => 1, 'blocked_hidden_count' => 1], + $report->details() + ); + } + + /** Audit L1: rights are checked in chunks, each with its own primed cache that is dropped afterwards. */ + public function testChecksLargeSetsInChunksAndReleasesTheCache(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, 450))); + $primed = []; + Functions\when('_prime_post_caches')->alias(static function (array $ids) use (&$primed): void { + $primed[] = count($ids); + }); + $deleted = 0; + Functions\when('wp_cache_delete')->alias(static function () use (&$deleted): bool { + ++$deleted; + + return true; + }); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id % 100 !== 0); + + $report = $usages->report(1058); + + self::assertSame([GlobalUsages::CHUNK, GlobalUsages::CHUNK, 50], $primed); + self::assertSame(450, $deleted); + self::assertSame(450, $report->count); + self::assertCount(GlobalUsages::LIMIT, $report->items); + self::assertSame(4, $report->hidden); + self::assertSame(4, $report->blockedHidden); + self::assertTrue($report->blocks()); + } + + /** Audit L1: above the hard cap nothing is checked for edit_post, and the report blocks. */ + public function testAboveTheHardCapNoPostIsCheckedForEditing(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, GlobalUsages::HARD_CAP + 1))); + Functions\when('get_post')->justReturn(null); + $asked = []; + Functions\when('current_user_can')->alias(static function (string $cap) use (&$asked): bool { + $asked[$cap] = ($asked[$cap] ?? 0) + 1; + + return true; + }); + + $report = $usages->report(1058); + + self::assertTrue($report->tooMany); + self::assertTrue($report->blocks()); + self::assertArrayNotHasKey('edit_post', $asked); + self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']); + } + + public function testAListingSkipsTheEditChecks(): void + { + $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053])); + Functions\when('get_post')->justReturn(null); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post'); + + $report = $usages->report(1058, false); + + self::assertFalse($report->blocks()); + self::assertSame(['affected', 'affected_count'], array_keys($report->listing())); } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index 8be91ab..d852cd7 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -37,11 +37,9 @@ final class LibraryListAbilityTest extends TestCase $limit + 1 )); $access = LibraryFixtures::access($deniedRoles, true, $store); - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->with(1058)->andReturn([ - 'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']], - 'count' => 1, - ]); + $usages = LibraryFixtures::usages( + static fn (): array => [['id' => 1053, 'status' => 'draft', 'type' => 'page']] + ); return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php index 20b41cc..21c1aaa 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php @@ -10,7 +10,6 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; -use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Tests\Support\DiviFixtures; @@ -48,9 +47,7 @@ final class DiviNodeCopyAbilityTest extends TestCase $access ??= LibraryFixtures::access(); $loader = LibraryFixtures::loader($access); $failures = []; - $usages = Mockery::mock(GlobalUsages::class); - $usages->allows('of')->andReturn(['items' => [], 'count' => 0]); - $globals = new GlobalEdits($access, $usages); + $globals = new GlobalEdits($access, LibraryFixtures::usages([])); return new DiviNodeCopyAbility( DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals),