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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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'])
|
||||
)
|
||||
. self::blockedNote($blocked),
|
||||
array_merge(
|
||||
$details,
|
||||
['affected' => $usages['items'], 'affected_count' => $usages['count']],
|
||||
self::blockedDetails($blocked)
|
||||
$report->count,
|
||||
self::pages($report->count)
|
||||
)
|
||||
. 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<int>
|
||||
* 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<int> $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<int> $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<int> $blocked
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
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<int> $ids At most LISTED_IDS of them, then "und N weitere". */
|
||||
|
||||
@@ -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":"<id>" – 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":"<id>" – 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<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();
|
||||
$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<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();
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
'affected' => $data['affected'] ?? [],
|
||||
'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.
|
||||
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'affected_count' => ['type' => 'integer'],
|
||||
'hidden_count' => ['type' => 'integer'],
|
||||
'backup_id' => ['type' => 'integer'],
|
||||
], ['tree_rev']);
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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<string> $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(
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user