fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4)

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:12:38 +02:00
co-authored by Claude Opus 5.5
parent bd28c32513
commit e8f69f67ea
12 changed files with 445 additions and 167 deletions
+5 -3
View File
@@ -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. 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. 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 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`; `affected` (betroffene Seiten, höchstens 50), `affected_count` (alle) und `hidden_count` (Seiten, die das
gespeichert wird nichts. 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 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`). `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`).
5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine 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 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 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 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). `unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei).
+71 -58
View File
@@ -28,7 +28,9 @@ defined('ABSPATH') || exit;
* copy is made first – the revision itself is DiviSaver's restore point, as for every page. * 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 * 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 * 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 * 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). * 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' => []]; return ['scope' => SaveScope::LibraryItem, 'details' => []];
} }
$usages = $this->usages->of($id); $report = $this->usages->report($id);
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
$blocked = $this->blocked($id);
if (($input['confirm'] ?? false) !== true) { if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired( return Errors::confirmRequired(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch ' 'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch '
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
$id, $id,
$usages['count'], $report->count,
self::pages($usages['count']) self::pages($report->count)
) . self::blockedNote($blocked), ) . self::hiddenNote($report) . self::blockedNote($report),
$details + self::blockedDetails($blocked) $report->details()
); );
} }
$refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item); $refusal = $this->pagesRefusal($id, $report) ?? $this->backupRefusal($item);
if ($refusal !== null) { if ($refusal !== null) {
return $refusal; return $refusal;
} }
$details = $report->listing();
if (! wp_revisions_enabled($item)) { if (! wp_revisions_enabled($item)) {
$details['backup_id'] = ($this->backup)($item); $details['backup_id'] = ($this->backup)($item);
} }
@@ -127,9 +128,7 @@ final class GlobalEdits
return $error; return $error;
} }
$id = (int) $page->post->ID; $report = $this->usages->report((int) $page->post->ID);
$usages = $this->usages->of($id);
$blocked = $this->blocked($id);
$data = $error->get_error_data(); $data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
@@ -137,79 +136,93 @@ final class GlobalEdits
Errors::clientMessage($error) Errors::clientMessage($error)
. sprintf( . sprintf(
' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).', ' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).',
$usages['count'], $report->count,
self::pages($usages['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 * Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above
* entry $id and that the user may not edit_post. Drafts and pending posts never block. * GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1).
*
* @return list<int>
*/ */
private function blocked(int $id): array private function pagesRefusal(int $id, UsageReport $report): ?WP_Error
{ {
$live = $this->usages->liveIds($id); if ($report->tooMany) {
if ($live === []) { return Errors::unsupported(
return []; 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); if (! $report->blocks()) {
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<int> $blocked
*/
private function pagesRefusal(int $id, array $blocked): ?WP_Error
{
if ($blocked === []) {
return null; return null;
} }
return Errors::forbidden( return Errors::forbidden(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, ' '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, $id,
self::listed($blocked) self::blockedList($report)
), ),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur ' 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator).' . 'oder Administrator).'
); );
} }
/** @param list<int> $blocked */ /** Audit L4: posts the caller may not read appear only as a count. */
private static function blockedNote(array $blocked): string private static function hiddenNote(UsageReport $report): string
{ {
return $blocked === [] ? '' : sprintf( return $report->hidden === 0 ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses ' ' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).',
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (IDs %s, siehe blocked_ids).', $report->hidden
self::listed($blocked)
); );
} }
/** private static function blockedNote(UsageReport $report): string
* @param list<int> $blocked
* @return array<string, list<int>>
*/
private static function blockedDetails(array $blocked): array
{ {
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<int> $ids At most LISTED_IDS of them, then "und N weitere". */ /** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
+108 -44
View File
@@ -4,87 +4,151 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library; namespace LIT\MCP\Integrations\Divi\Library;
use WP_Post;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Pages (and Theme Builder layouts) that show global library entry $id: every living post whose * Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
* content has "globalModule":"<id>" – Divi's own way to find instances (string scan, * living post whose content has "globalModule":"<id>" – Divi's own way to find instances (string
* BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it. * 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 class GlobalUsages
{ {
/** At most this many readable posts are listed. */
public const LIMIT = 50; 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. */ /** Every post that exists for the user: listed in the dry run. */
private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')"; 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) public function __construct(private ?\wpdb $db = null)
{ {
} }
/** @return array{items: list<array{post_id: int, title: string, status: string, type: string}>, 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<array{id: int, status: string, type: string}>
*/
public function rows(int $libraryId): array
{ {
$db = $this->db(); $db = $this->db();
$like = self::like($db, $libraryId);
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery // 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( $rows = $db->get_results(
$db->prepare( $db->prepare(
'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND '
. self::where(self::LISTED) . ' ORDER BY ID ASC LIMIT %d', . "post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
$like, self::like($db, $libraryId)
self::LIMIT
), ),
'ARRAY_A' 'ARRAY_A'
); );
// phpcs:enable // phpcs:enable
$items = []; $result = [];
foreach (is_array($rows) ? $rows : [] as $row) { foreach (is_array($rows) ? $rows : [] as $row) {
$id = (int) $row['ID']; $result[] = [
$items[] = [ 'id' => (int) $row['ID'],
'post_id' => $id,
'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)',
'status' => (string) $row['post_status'], 'status' => (string) $row['post_status'],
'type' => (string) $row['post_type'], '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: whether the caller may edit_post the live post $row.
* (ruling P8a checks edit_post on each of them before a confirmed global edit).
* *
* @return list<int> * @param array{id: int, status: string, type: string} $row
*/ */
public function liveIds(int $libraryId): array protected function editable(array $row): bool
{ {
$db = $this->db(); return current_user_can('edit_post', $row['id']);
// 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;
} }
private static function like(\wpdb $db, int $libraryId): string private static function like(\wpdb $db, int $libraryId): string
@@ -103,6 +103,7 @@ final class LibraryListAbility implements Ability
'edit_url' => ['type' => 'string'], 'edit_url' => ['type' => 'string'],
'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'], 'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
], ],
'required' => ['items', 'next_cursor'], 'required' => ['items', 'next_cursor'],
]; ];
@@ -232,9 +233,7 @@ final class LibraryListAbility implements Ability
'edit_url' => $page->editUrl(), 'edit_url' => $page->editUrl(),
]; ];
if ($item['scope'] === 'global') { if ($item['scope'] === 'global') {
$usages = $this->usages->of((int) $page->post->ID); $result += $this->usages->report((int) $page->post->ID, false)->listing();
$result['affected'] = $usages['items'];
$result['affected_count'] = $usages['count'];
} }
return $result; return $result;
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* The posts that show one library entry, as the caller may see them (GlobalUsages::report()):
* readable posts by ID and title, unreadable ones only as counts (audit L4). Pure PHP.
*/
final class UsageReport
{
/**
* @param int $count Every post that shows the entry.
* @param list<array{post_id: int, title: string, status: string, type: string}> $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<int> $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<string, mixed> 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<string, mixed> 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] : []);
}
}
@@ -404,7 +404,7 @@ final class DiviReplaceTextAbility implements Ability
. 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.', . 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
'affected' => $data['affected'] ?? [], 'affected' => $data['affected'] ?? [],
'affected_count' => $data['affected_count'] ?? 0, 'affected_count' => $data['affected_count'] ?? 0,
]; ] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []);
} }
/** /**
@@ -39,6 +39,7 @@ abstract class DiviWriteAbility implements Ability
// made when revisions are off. // made when revisions are off.
'affected' => ['type' => 'array', 'items' => ['type' => 'object']], 'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'], 'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
'backup_id' => ['type' => 'integer'], 'backup_id' => ['type' => 'integer'],
], ['tree_rev']); ], ['tree_rev']);
} }
+24
View File
@@ -8,6 +8,7 @@ use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles; 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\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader; use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use Mockery; use Mockery;
@@ -111,6 +112,29 @@ final class LibraryFixtures
return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable); 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<array{id: int, status: string, type: string}>|\Closure(): list<array{id: int, status: string, type: string}> $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<array{id: int, status: string, type: string}> 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 public static function loader(?LibraryAccess $access = null): DiviPageLoader
{ {
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access()); return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access());
@@ -50,19 +50,16 @@ final class GlobalEditsTest extends TestCase
Functions\when('_prime_post_caches')->justReturn(null); Functions\when('_prime_post_caches')->justReturn(null);
} }
/** The live posts ($this->live; 1053 published, the others private) and draft 1070. */
private function usages(): GlobalUsages private function usages(): GlobalUsages
{ {
$usages = Mockery::mock(GlobalUsages::class); return LibraryFixtures::usages(fn (): array => array_merge(
$usages->allows('of')->andReturn([ array_map(
'items' => [ static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'],
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'], $this->live
['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'], ),
], [['id' => 1070, 'status' => 'draft', 'type' => 'page']]
'count' => 2, ));
]);
$usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live);
return $usages;
} }
/** @param list<string> $deniedRoles */ /** @param list<string> $deniedRoles */
@@ -274,6 +271,60 @@ final class GlobalEditsTest extends TestCase
self::assertStringContainsString('abgelehnt', $dryRun->get_error_message()); 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 public function testADryRunWithoutBlockersHasNoBlockedIds(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1059)]); LibraryFixtures::given([LibraryFixtures::item(1059)]);
@@ -329,12 +380,7 @@ final class GlobalEditsTest extends TestCase
public function testDeleteNoticeUsesTheSingularForOnePage(): void public function testDeleteNoticeUsesTheSingularForOnePage(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1058)]); LibraryFixtures::given([LibraryFixtures::item(1058)]);
$usages = Mockery::mock(GlobalUsages::class); $usages = LibraryFixtures::usages(LibraryFixtures::rows([1053]));
$usages->allows('of')->andReturn([
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']],
'count' => 1,
]);
$usages->allows('liveIds')->andReturn([1053]);
$access = LibraryFixtures::access(); $access = LibraryFixtures::access();
$design = DiviWriterFixture::designReader(); $design = DiviWriterFixture::designReader();
$writer = new DiviWriter( $writer = new DiviWriter(
@@ -6,60 +6,133 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use WP_Post;
final class GlobalUsagesTest extends TestCase 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(); $db = Mockery::mock(\wpdb::class)->makePartial();
$queries = []; $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 { $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql; $queries[] = $sql;
return [ return [
['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'], ['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'],
['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'], ['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( self::assertSame(
[ [
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'], ['id' => 1053, 'status' => 'draft', 'type' => 'page'],
['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'], ['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("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]); self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
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_type <> 'revision'", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertStringNotContainsString('LIMIT', $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()));
} }
} }
@@ -37,11 +37,9 @@ final class LibraryListAbilityTest extends TestCase
$limit + 1 $limit + 1
)); ));
$access = LibraryFixtures::access($deniedRoles, true, $store); $access = LibraryFixtures::access($deniedRoles, true, $store);
$usages = Mockery::mock(GlobalUsages::class); $usages = LibraryFixtures::usages(
$usages->allows('of')->with(1058)->andReturn([ static fn (): array => [['id' => 1053, 'status' => 'draft', 'type' => 'page']]
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']], );
'count' => 1,
]);
return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages); return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages);
} }
@@ -10,7 +10,6 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits; 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\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviFixtures;
@@ -48,9 +47,7 @@ final class DiviNodeCopyAbilityTest extends TestCase
$access ??= LibraryFixtures::access(); $access ??= LibraryFixtures::access();
$loader = LibraryFixtures::loader($access); $loader = LibraryFixtures::loader($access);
$failures = []; $failures = [];
$usages = Mockery::mock(GlobalUsages::class); $globals = new GlobalEdits($access, LibraryFixtures::usages([]));
$usages->allows('of')->andReturn(['items' => [], 'count' => 0]);
$globals = new GlobalEdits($access, $usages);
return new DiviNodeCopyAbility( return new DiviNodeCopyAbility(
DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals), DiviWriterFixture::writer($this->saved, $failures, $unfiltered, $loader, $globals),