fix: refuse private/unpublished menu links, write only changed menu items
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -66,16 +66,18 @@ final class MenuOperations
|
|||||||
return 'Objekt erwartet.';
|
return 'Objekt erwartet.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$op = $operation['op'] ?? '';
|
||||||
$parentId = (int) ($operation['parent_id'] ?? 0);
|
$parentId = (int) ($operation['parent_id'] ?? 0);
|
||||||
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
|
$parentKey = $parentId > 0 ? 'id:' . $parentId : self::ROOT;
|
||||||
if (! isset($children[$parentKey])) {
|
// parent_id only matters where something is placed; remove ignores it.
|
||||||
|
if (in_array($op, ['add', 'move'], true) && ! isset($children[$parentKey])) {
|
||||||
return 'parent_id ist kein Eintrag dieses Menüs.';
|
return 'parent_id ist kein Eintrag dieses Menüs.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$position = isset($operation['position']) ? (int) $operation['position'] : null;
|
$position = isset($operation['position']) ? (int) $operation['position'] : null;
|
||||||
$key = 'id:' . (int) ($operation['id'] ?? 0);
|
$key = 'id:' . (int) ($operation['id'] ?? 0);
|
||||||
|
|
||||||
switch ($operation['op'] ?? '') {
|
switch ($op) {
|
||||||
case 'add':
|
case 'add':
|
||||||
if (! is_array($operation['item'] ?? null)) {
|
if (! is_array($operation['item'] ?? null)) {
|
||||||
return 'item fehlt.';
|
return 'item fehlt.';
|
||||||
|
|||||||
@@ -46,12 +46,17 @@ final class MenuSetItemsAbility implements Ability
|
|||||||
. 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge '
|
. 'Der Baum aus lit-menu-get kann bearbeitet unverändert zurückgegeben werden: bestehende Einträge '
|
||||||
. 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ '
|
. 'werden über id erkannt, type/object/object_id und bei post/taxonomy auch url sind nur informativ '
|
||||||
. 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom '
|
. 'und werden ignoriert; title wird nur bei Änderung zur eigenen Beschriftung, url nur bei custom '
|
||||||
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id (muss für dich lesbar sein) oder '
|
. 'geändert. Neue Einträge ohne id: type post|taxonomy mit object_id oder custom mit url (/pfad, '
|
||||||
. 'custom mit url (/pfad, #anker, http(s), mailto, tel) und title, jeweils optional children. Das '
|
. '#anker, http(s), mailto, tel) und title, jeweils optional children. Verlinkt werden nur für dich '
|
||||||
|
. 'lesbare, veröffentlichte Inhalte und Begriffe öffentlicher Taxonomien; private Inhalte nie. '
|
||||||
|
. 'Entwürfe/ausstehende/geplante Inhalte nur mit allow_unpublished: true – WordPress zeigt sie sofort '
|
||||||
|
. 'im Menü, Besucher erhalten bis zur Veröffentlichung einen toten Link. Das '
|
||||||
. 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann '
|
. 'Ziel eines bestehenden Eintrags ändern: entfernen und neu anlegen. Alles wird vorab geprüft, dann '
|
||||||
. 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – '
|
. 'ganz oder gar nicht geschrieben. confirm: true ist nur nötig, wenn dabei Einträge wegfallen – '
|
||||||
. 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält '
|
. 'Menüeinträge haben keinen Papierkorb und werden endgültig gelöscht. Die Antwort enthält '
|
||||||
. 'previous_items (Stand vorher) zum Zurückbauen.';
|
. 'previous_items (Stand vorher) zum Zurückbauen; entfernte Einträge lassen sich daraus nur mit '
|
||||||
|
. 'Titel, Ziel und Position neu anlegen (neue id; CSS-Klassen, Linkziel, Beschreibung gehen verloren). '
|
||||||
|
. 'Nur tatsächlich geänderte Einträge werden geschrieben.';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function inputSchema(): array
|
public function inputSchema(): array
|
||||||
@@ -81,6 +86,11 @@ final class MenuSetItemsAbility implements Ability
|
|||||||
'additionalProperties' => false,
|
'additionalProperties' => false,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'allow_unpublished' => [
|
||||||
|
'type' => 'boolean',
|
||||||
|
'description' => 'Links auf Entwürfe/ausstehende/geplante Inhalte erlauben (sofort sichtbar, '
|
||||||
|
. 'bis zur Veröffentlichung toter Link).',
|
||||||
|
],
|
||||||
'confirm' => [
|
'confirm' => [
|
||||||
'type' => 'boolean',
|
'type' => 'boolean',
|
||||||
'description' => 'Nur nötig, wenn Einträge entfernt werden.',
|
'description' => 'Nur nötig, wenn Einträge entfernt werden.',
|
||||||
@@ -140,7 +150,7 @@ final class MenuSetItemsAbility implements Ability
|
|||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
$plan = $this->writer->plan($tree, $current);
|
$plan = $this->writer->plan($tree, $current, ($input['allow_unpublished'] ?? false) === true);
|
||||||
if ($plan['errors'] !== []) {
|
if ($plan['errors'] !== []) {
|
||||||
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
|
return Errors::invalidInput('Menübaum ist ungültig; nichts wurde geändert.', $plan['errors']);
|
||||||
}
|
}
|
||||||
@@ -160,19 +170,22 @@ final class MenuSetItemsAbility implements Ability
|
|||||||
|
|
||||||
// The menu is saved – from here on failures are logged and reported as warnings only.
|
// The menu is saved – from here on failures are logged and reported as warnings only.
|
||||||
$warnings = $plan['warnings'];
|
$warnings = $plan['warnings'];
|
||||||
$summary = sprintf(
|
$changed = array_sum($stats) > 0;
|
||||||
|
$summary = $changed
|
||||||
|
? sprintf(
|
||||||
'Menü "%s": %d neu, %d geändert, %d entfernt',
|
'Menü "%s": %d neu, %d geändert, %d entfernt',
|
||||||
$menu->name,
|
$menu->name,
|
||||||
$stats['created'],
|
$stats['created'],
|
||||||
$stats['updated'],
|
$stats['updated'],
|
||||||
$stats['removed']
|
$stats['removed']
|
||||||
);
|
)
|
||||||
$logId = FollowUp::run(
|
: sprintf('Menü "%s": keine Änderung', $menu->name);
|
||||||
|
$logId = $changed ? FollowUp::run(
|
||||||
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
|
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
|
||||||
$this->name() . ' change log',
|
$this->name() . ' change log',
|
||||||
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist gespeichert.',
|
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist gespeichert.',
|
||||||
$warnings
|
$warnings
|
||||||
);
|
) : null;
|
||||||
$after = FollowUp::run(
|
$after = FollowUp::run(
|
||||||
fn () => MenuTree::build(array_values($this->currentItems($menuId))),
|
fn () => MenuTree::build(array_values($this->currentItems($menuId))),
|
||||||
$this->name() . ' re-read',
|
$this->name() . ' re-read',
|
||||||
@@ -180,8 +193,12 @@ final class MenuSetItemsAbility implements Ability
|
|||||||
$warnings
|
$warnings
|
||||||
);
|
);
|
||||||
|
|
||||||
return ToolResult::ok($summary . '.')
|
$result = ToolResult::ok($summary . '.');
|
||||||
->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name)
|
if ($changed) {
|
||||||
|
$result->changed((string) $menuId, 'nav_menu', 'updated', (string) $menu->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result
|
||||||
->warnings($warnings)
|
->warnings($warnings)
|
||||||
->logId(is_int($logId) ? $logId : null)
|
->logId(is_int($logId) ? $logId : null)
|
||||||
->with('items', is_array($after) ? $after : [])
|
->with('items', is_array($after) ? $after : [])
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ use WP_Term;
|
|||||||
* its id alone; type/object/object_id and – for post/taxonomy items – url are
|
* its id alone; type/object/object_id and – for post/taxonomy items – url are
|
||||||
* informational and ignored. Existing items keep all their data (classes, target,
|
* informational and ignored. Existing items keep all their data (classes, target,
|
||||||
* description, custom label, …); title only becomes a custom label when it differs
|
* description, custom label, …); title only becomes a custom label when it differs
|
||||||
* from the currently shown title, url only changes for custom items.
|
* from the currently shown title, url only changes for custom items. Only items whose
|
||||||
|
* parent, position, title or url actually change are rewritten (and counted as updated).
|
||||||
*
|
*
|
||||||
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
|
* apply() is all-or-nothing as far as WordPress allows: on a failure it deletes the
|
||||||
* items it created and rewrites the touched items with their previous data. Deleted
|
* items it created and rewrites the touched items with their previous data. Deleted
|
||||||
@@ -27,6 +28,8 @@ use WP_Term;
|
|||||||
class MenuWriter
|
class MenuWriter
|
||||||
{
|
{
|
||||||
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
|
private const NON_LINKABLE = ['revision', 'nav_menu_item', 'attachment'];
|
||||||
|
/** Statuses WordPress still shows in menus although visitors get a dead link until publication. */
|
||||||
|
private const UNPUBLISHED = ['draft', 'pending', 'future'];
|
||||||
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
private const URL_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
||||||
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
|
private const URL_RULE = '/pfad, #anker, http(s), mailto, tel';
|
||||||
|
|
||||||
@@ -39,20 +42,25 @@ class MenuWriter
|
|||||||
/** @var list<int> Items deleted by the running apply(). */
|
/** @var list<int> Items deleted by the running apply(). */
|
||||||
private array $deleted = [];
|
private array $deleted = [];
|
||||||
|
|
||||||
|
/** Set by plan(): links to draft/pending/future posts are allowed. */
|
||||||
|
private bool $allowUnpublished = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<mixed> $tree
|
* @param list<mixed> $tree
|
||||||
* @param array<int, object> $current Current items by ID.
|
* @param array<int, object> $current Current items by ID.
|
||||||
|
* @param bool $allowUnpublished Allow links to draft/pending/future posts (with a warning).
|
||||||
* @return array{
|
* @return array{
|
||||||
* errors: array<string, string>,
|
* errors: array<string, string>,
|
||||||
* removals: list<array{id: int, title: string}>,
|
* removals: list<array{id: int, title: string}>,
|
||||||
* warnings: list<string>
|
* warnings: list<string>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function plan(array $tree, array $current): array
|
public function plan(array $tree, array $current, bool $allowUnpublished = false): array
|
||||||
{
|
{
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
$this->allowUnpublished = $allowUnpublished;
|
||||||
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
|
$this->validateNodes($tree, 'items', $current, $seen, $errors, $warnings);
|
||||||
|
|
||||||
$removals = [];
|
$removals = [];
|
||||||
@@ -195,19 +203,13 @@ class MenuWriter
|
|||||||
) {
|
) {
|
||||||
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
|
return 'object_id muss ein vorhandener, für dich lesbarer Eintrag sein.';
|
||||||
}
|
}
|
||||||
if ($post->post_status !== 'publish') {
|
return $this->checkStatus($post, $warnings);
|
||||||
$warnings[] = sprintf(
|
|
||||||
'"%s" ist nicht veröffentlicht und erscheint erst nach dem Veröffentlichen im Menü.',
|
|
||||||
$post->post_title
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
case 'taxonomy':
|
case 'taxonomy':
|
||||||
$term = is_int($objectId) ? get_term($objectId) : null;
|
$term = is_int($objectId) ? get_term($objectId) : null;
|
||||||
return $term instanceof WP_Term
|
// Terms of non-public taxonomies would show internal structure in a public menu.
|
||||||
&& (is_taxonomy_viewable($term->taxonomy) || current_user_can('edit_term', $objectId))
|
return $term instanceof WP_Term && is_taxonomy_viewable($term->taxonomy)
|
||||||
? null
|
? null
|
||||||
: 'object_id muss ein vorhandener, für dich lesbarer Begriff sein.';
|
: 'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.';
|
||||||
case 'custom':
|
case 'custom':
|
||||||
if (! $this->validUrl($node['url'] ?? null)) {
|
if (! $this->validUrl($node['url'] ?? null)) {
|
||||||
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
|
return 'Individueller Link braucht eine gültige url (' . self::URL_RULE . ').';
|
||||||
@@ -220,6 +222,45 @@ class MenuWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress shows menu items of draft/pending/private/future posts on the live site right
|
||||||
|
* away (only trashed or missing objects are hidden), so private posts are always refused
|
||||||
|
* and unpublished ones only with allow_unpublished.
|
||||||
|
*
|
||||||
|
* @param list<string> $warnings
|
||||||
|
*/
|
||||||
|
private function checkStatus(WP_Post $post, array &$warnings): ?string
|
||||||
|
{
|
||||||
|
$status = (string) $post->post_status;
|
||||||
|
if ($status === 'publish') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'private') {
|
||||||
|
return 'Private Inhalte dürfen nicht in öffentlichen Menüs erscheinen (Besucher sähen Titel und Link).';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($status, self::UNPUBLISHED, true)) {
|
||||||
|
return sprintf('Einträge mit Status %s können nicht verlinkt werden.', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->allowUnpublished) {
|
||||||
|
return sprintf(
|
||||||
|
'"%s" ist nicht veröffentlicht (%s), würde aber sofort im Menü erscheinen – als toter Link. '
|
||||||
|
. 'Erst veröffentlichen oder allow_unpublished: true setzen.',
|
||||||
|
$post->post_title,
|
||||||
|
$status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings[] = sprintf(
|
||||||
|
'Eintrag "%s" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.',
|
||||||
|
$post->post_title
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $node */
|
/** @param array<string, mixed> $node */
|
||||||
private function urlChanges(array $node, object $item): bool
|
private function urlChanges(array $node, object $item): bool
|
||||||
{
|
{
|
||||||
@@ -234,12 +275,17 @@ class MenuWriter
|
|||||||
|
|
||||||
private function validUrl(mixed $url): bool
|
private function validUrl(mixed $url): bool
|
||||||
{
|
{
|
||||||
if (! is_string($url) || $url === '' || preg_match('/\s/', $url) === 1) {
|
if (! is_string($url) || $url === '' || preg_match('/[\s\x00-\x1f\x7f]/', $url) === 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
|
if (str_starts_with($url, '/')) {
|
||||||
return ! str_starts_with($url, '//');
|
// "//host" and "/\host" are protocol-relative links to another host in browsers.
|
||||||
|
return ! in_array(substr($url, 1, 1), ['/', '\\'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($url, '#')) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
|
$scheme = wp_parse_url($url, PHP_URL_SCHEME);
|
||||||
@@ -263,27 +309,79 @@ class MenuWriter
|
|||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
$position++;
|
$position++;
|
||||||
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
|
$existing = self::isExisting($node) ? $current[(int) $node['id']] : null;
|
||||||
$args = $existing !== null ? $this->argsFromItem($existing) : $this->argsFromNode($node);
|
if ($existing !== null) {
|
||||||
if ($existing === null ? isset($node['title']) : $this->titleChanges($node, $existing)) {
|
$itemId = (int) $existing->ID;
|
||||||
|
$seen[$itemId] = true;
|
||||||
|
$titleChanges = $this->titleChanges($node, $existing);
|
||||||
|
$urlChanges = $this->customUrlChanges($node, $existing);
|
||||||
|
$moved = $parentId !== (int) $existing->menu_item_parent
|
||||||
|
|| $position !== $this->storedPosition($current, $itemId);
|
||||||
|
if ($titleChanges || $urlChanges || $moved) {
|
||||||
|
$args = $this->withNode(
|
||||||
|
$this->argsFromItem($existing),
|
||||||
|
$node,
|
||||||
|
$titleChanges,
|
||||||
|
$urlChanges,
|
||||||
|
$parentId,
|
||||||
|
$position
|
||||||
|
);
|
||||||
|
$this->save($menuId, $itemId, $args);
|
||||||
|
$this->touched[] = $itemId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$args = $this->withNode(
|
||||||
|
$this->argsFromNode($node),
|
||||||
|
$node,
|
||||||
|
isset($node['title']),
|
||||||
|
$node['type'] === 'custom',
|
||||||
|
$parentId,
|
||||||
|
$position
|
||||||
|
);
|
||||||
|
$itemId = $this->save($menuId, 0, $args);
|
||||||
|
$this->created[] = $itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeNodes($menuId, (array) ($node['children'] ?? []), $itemId, $current, $position, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $args
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withNode(
|
||||||
|
array $args,
|
||||||
|
array $node,
|
||||||
|
bool $setTitle,
|
||||||
|
bool $setUrl,
|
||||||
|
int $parentId,
|
||||||
|
int $position
|
||||||
|
): array {
|
||||||
|
if ($setTitle) {
|
||||||
$args['menu-item-title'] = (string) $node['title'];
|
$args['menu-item-title'] = (string) $node['title'];
|
||||||
}
|
}
|
||||||
if ($existing === null ? $node['type'] === 'custom' : $this->customUrlChanges($node, $existing)) {
|
if ($setUrl) {
|
||||||
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
$args['menu-item-url'] = esc_url_raw((string) $node['url']);
|
||||||
}
|
}
|
||||||
$args['menu-item-parent-id'] = $parentId;
|
$args['menu-item-parent-id'] = $parentId;
|
||||||
$args['menu-item-position'] = $position;
|
$args['menu-item-position'] = $position;
|
||||||
$args['menu-item-status'] = 'publish';
|
$args['menu-item-status'] = 'publish';
|
||||||
|
|
||||||
$itemId = $this->save($menuId, $existing !== null ? (int) $existing->ID : 0, $args);
|
return $args;
|
||||||
if ($existing !== null) {
|
|
||||||
$seen[(int) $existing->ID] = true;
|
|
||||||
$this->touched[] = (int) $existing->ID;
|
|
||||||
} else {
|
|
||||||
$this->created[] = $itemId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->writeNodes($menuId, (array) ($node['children'] ?? []), $itemId, $current, $position, $seen);
|
/**
|
||||||
}
|
* The item's current position: its menu_order, or – when that is missing – its index in
|
||||||
|
* $current (which is in menu order).
|
||||||
|
*
|
||||||
|
* @param array<int, object> $current
|
||||||
|
*/
|
||||||
|
private function storedPosition(array $current, int $itemId): int
|
||||||
|
{
|
||||||
|
$order = (int) ($current[$itemId]->menu_order ?? 0);
|
||||||
|
|
||||||
|
return $order > 0 ? $order : (int) array_search($itemId, array_keys($current), true) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $node */
|
/** @param array<string, mixed> $node */
|
||||||
@@ -338,14 +436,11 @@ class MenuWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$order = array_flip(array_keys($current));
|
|
||||||
foreach ($this->touched as $itemId) {
|
foreach ($this->touched as $itemId) {
|
||||||
$item = $current[$itemId];
|
$item = $current[$itemId];
|
||||||
$args = $this->argsFromItem($item);
|
$args = $this->argsFromItem($item);
|
||||||
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
|
$args['menu-item-parent-id'] = (int) $item->menu_item_parent;
|
||||||
$args['menu-item-position'] = isset($item->menu_order) && (int) $item->menu_order > 0
|
$args['menu-item-position'] = $this->storedPosition($current, $itemId);
|
||||||
? (int) $item->menu_order
|
|
||||||
: $order[$itemId] + 1;
|
|
||||||
$args['menu-item-status'] = 'publish';
|
$args['menu-item-status'] = 'publish';
|
||||||
try {
|
try {
|
||||||
$this->save($menuId, $itemId, $args);
|
$this->save($menuId, $itemId, $args);
|
||||||
|
|||||||
@@ -62,4 +62,11 @@ final class MenuOperationsTest extends TestCase
|
|||||||
$result
|
$result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRemoveIgnoresParentId(): void
|
||||||
|
{
|
||||||
|
$result = (new MenuOperations())->apply($this->tree(), [['op' => 'remove', 'id' => 3, 'parent_id' => 99]]);
|
||||||
|
|
||||||
|
self::assertSame([1], array_column($result, 'id'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,19 +128,77 @@ final class MenuSetItemsAbilityTest extends TestCase
|
|||||||
$items = [self::item(10, 0, 'Start'), self::item(11, 10, 'Team'), self::item(12, 0, 'Kontakt', 'custom', '/k')];
|
$items = [self::item(10, 0, 'Start'), self::item(11, 10, 'Team'), self::item(12, 0, 'Kontakt', 'custom', '/k')];
|
||||||
$items[0]->url = 'https://example.test/';
|
$items[0]->url = 'https://example.test/';
|
||||||
Functions\when('wp_get_nav_menu_items')->justReturn($items);
|
Functions\when('wp_get_nav_menu_items')->justReturn($items);
|
||||||
Functions\when('wp_update_nav_menu_item')->alias(static fn (int $menu, int $id): int => $id);
|
|
||||||
Functions\expect('wp_delete_post')->never();
|
Functions\expect('wp_delete_post')->never();
|
||||||
|
Functions\expect('wp_update_nav_menu_item')->never();
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->allows('record')->andReturn(23);
|
$log->expects('record')->never();
|
||||||
|
|
||||||
$result = (new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log))->execute(
|
$result = (new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log))->execute(
|
||||||
['menu_id' => 3, 'items' => MenuTree::build($items)]
|
['menu_id' => 3, 'items' => MenuTree::build($items)]
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertStringContainsString('0 neu, 3 geändert, 0 entfernt', $result['summary']);
|
self::assertSame('Menü "Hauptmenü": keine Änderung.', $result['summary']);
|
||||||
|
self::assertSame([], $result['changed']);
|
||||||
|
self::assertNull($result['log_id']);
|
||||||
self::assertSame($result['previous_items'], $result['items']);
|
self::assertSame($result['previous_items'], $result['items']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAllowUnpublishedIsPassedToThePlan(): void
|
||||||
|
{
|
||||||
|
$writer = Mockery::mock(MenuWriter::class);
|
||||||
|
$writer->expects('plan')->with(Mockery::type('array'), Mockery::type('array'), true)
|
||||||
|
->andReturn(['errors' => ['items.0' => 'x'], 'removals' => [], 'warnings' => []]);
|
||||||
|
$ability = new MenuSetItemsAbility($writer, new MenuOperations(), Mockery::mock(ChangeLog::class));
|
||||||
|
|
||||||
|
$result = $ability->execute(
|
||||||
|
['menu_id' => 3, 'items' => [['type' => 'post', 'object_id' => 7]], 'allow_unpublished' => true]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
self::assertArrayHasKey('allow_unpublished', $ability->inputSchema()['properties']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADraftLinkIsRefusedWithoutTheFlagAndWrittenWithAWarningWithIt(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(
|
||||||
|
new \WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Neu'])
|
||||||
|
);
|
||||||
|
Functions\when('get_post_type')->justReturn('page');
|
||||||
|
Functions\expect('wp_update_nav_menu_item')->once()->andReturn(60);
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->allows('record')->andReturn(24);
|
||||||
|
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), $log);
|
||||||
|
$input = ['menu_id' => 3, 'operations' => [['op' => 'add', 'item' => ['type' => 'post', 'object_id' => 7]]]];
|
||||||
|
|
||||||
|
$refused = $ability->execute($input);
|
||||||
|
$allowed = $ability->execute($input + ['allow_unpublished' => true]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $refused->get_error_code());
|
||||||
|
self::assertStringContainsString('allow_unpublished', $refused->get_error_data()['fields']['items.1']);
|
||||||
|
self::assertStringContainsString('1 neu', $allowed['summary']);
|
||||||
|
self::assertStringContainsString('sofort im Menü sichtbar', $allowed['warnings'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPrivatePostIsNeverLinked(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(
|
||||||
|
new \WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'private', 'post_title' => 'Intern'])
|
||||||
|
);
|
||||||
|
Functions\expect('wp_update_nav_menu_item')->never();
|
||||||
|
$ability = new MenuSetItemsAbility(new MenuWriter(), new MenuOperations(), Mockery::mock(ChangeLog::class));
|
||||||
|
|
||||||
|
$result = $ability->execute([
|
||||||
|
'menu_id' => 3,
|
||||||
|
'allow_unpublished' => true,
|
||||||
|
'operations' => [['op' => 'add', 'item' => ['type' => 'post', 'object_id' => 7]]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||||
|
self::assertStringContainsString('Private Inhalte', $result->get_error_data()['fields']['items.1']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAWriteFailureIsPassedOnAsWriteFailed(): void
|
public function testAWriteFailureIsPassedOnAsWriteFailed(): void
|
||||||
{
|
{
|
||||||
$writer = Mockery::mock(MenuWriter::class);
|
$writer = Mockery::mock(MenuWriter::class);
|
||||||
@@ -190,5 +248,6 @@ final class MenuSetItemsAbilityTest extends TestCase
|
|||||||
self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations());
|
self::assertSame(['readonly' => false, 'destructive' => true, 'idempotent' => false], $ability->annotations());
|
||||||
self::assertStringContainsString('confirm: true', $ability->description());
|
self::assertStringContainsString('confirm: true', $ability->description());
|
||||||
self::assertStringContainsString('nur nötig', $ability->description());
|
self::assertStringContainsString('nur nötig', $ability->description());
|
||||||
|
self::assertStringContainsString('CSS-Klassen', $ability->description());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ final class MenuWriterTest extends TestCase
|
|||||||
['type' => 'post', 'object_id' => 7],
|
['type' => 'post', 'object_id' => 7],
|
||||||
['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'],
|
['type' => 'custom', 'url' => 'javascript:alert(1)', 'title' => 'X'],
|
||||||
['type' => 'custom', 'url' => '/kontakt'],
|
['type' => 'custom', 'url' => '/kontakt'],
|
||||||
], $this->current());
|
], $this->current(), true);
|
||||||
|
|
||||||
self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']);
|
self::assertStringContainsString('mehrfach', $plan['errors']['items.0.children.0']);
|
||||||
self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']);
|
self::assertStringContainsString('kein Eintrag dieses Menüs', $plan['errors']['items.1']);
|
||||||
@@ -73,7 +73,45 @@ final class MenuWriterTest extends TestCase
|
|||||||
self::assertStringContainsString('url', $plan['errors']['items.3']);
|
self::assertStringContainsString('url', $plan['errors']['items.3']);
|
||||||
self::assertStringContainsString('title', $plan['errors']['items.4']);
|
self::assertStringContainsString('title', $plan['errors']['items.4']);
|
||||||
self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']);
|
self::assertSame([['id' => 10, 'title' => 'Home']], $plan['removals']);
|
||||||
self::assertStringContainsString('"Neu" ist nicht veröffentlicht', $plan['warnings'][0]);
|
self::assertSame(
|
||||||
|
['Eintrag "Neu" ist sofort im Menü sichtbar; Besucher erhalten bis zur Veröffentlichung einen toten Link.'],
|
||||||
|
$plan['warnings']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{0: string, 1: bool, 2: bool}> status, allow_unpublished, accepted */
|
||||||
|
public static function statuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'published' => ['publish', false, true],
|
||||||
|
'private is always refused' => ['private', true, false],
|
||||||
|
'draft without flag' => ['draft', false, false],
|
||||||
|
'pending without flag' => ['pending', false, false],
|
||||||
|
'future without flag' => ['future', false, false],
|
||||||
|
'draft with flag' => ['draft', true, true],
|
||||||
|
'future with flag' => ['future', true, true],
|
||||||
|
'trash' => ['trash', true, false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider statuses */
|
||||||
|
public function testPostStatusDecidesWhetherItCanBeLinked(string $status, bool $allow, bool $accepted): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(
|
||||||
|
new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => $status, 'post_title' => 'Seite'])
|
||||||
|
);
|
||||||
|
|
||||||
|
$plan = (new MenuWriter())->plan([['type' => 'post', 'object_id' => 7]], [], $allow);
|
||||||
|
|
||||||
|
self::assertSame($accepted, ! isset($plan['errors']['items.0']), $plan['errors']['items.0'] ?? '');
|
||||||
|
self::assertSame($accepted && $status !== 'publish' ? 1 : 0, count($plan['warnings']));
|
||||||
|
if ($status === 'private') {
|
||||||
|
self::assertStringContainsString('Private Inhalte', $plan['errors']['items.0']);
|
||||||
|
}
|
||||||
|
if (! $accepted && in_array($status, ['draft', 'pending', 'future'], true)) {
|
||||||
|
self::assertStringContainsString('allow_unpublished', $plan['errors']['items.0']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPlanRejectsChildrenThatAreNoListAndNodesThatAreNoObject(): void
|
public function testPlanRejectsChildrenThatAreNoListAndNodesThatAreNoObject(): void
|
||||||
@@ -92,7 +130,6 @@ final class MenuWriterTest extends TestCase
|
|||||||
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 4, 'taxonomy' => 'internal']));
|
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 4, 'taxonomy' => 'internal']));
|
||||||
Functions\when('is_taxonomy_viewable')->justReturn(false);
|
Functions\when('is_taxonomy_viewable')->justReturn(false);
|
||||||
Functions\expect('current_user_can')->with('read_post', 7)->andReturn(false);
|
Functions\expect('current_user_can')->with('read_post', 7)->andReturn(false);
|
||||||
Functions\expect('current_user_can')->with('edit_term', 4)->andReturn(false);
|
|
||||||
|
|
||||||
$plan = (new MenuWriter())->plan(
|
$plan = (new MenuWriter())->plan(
|
||||||
[['type' => 'post', 'object_id' => 7], ['type' => 'taxonomy', 'object_id' => 4]],
|
[['type' => 'post', 'object_id' => 7], ['type' => 'taxonomy', 'object_id' => 4]],
|
||||||
@@ -100,7 +137,10 @@ final class MenuWriterTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame('object_id muss ein vorhandener, für dich lesbarer Eintrag sein.', $plan['errors']['items.0']);
|
self::assertSame('object_id muss ein vorhandener, für dich lesbarer Eintrag sein.', $plan['errors']['items.0']);
|
||||||
self::assertSame('object_id muss ein vorhandener, für dich lesbarer Begriff sein.', $plan['errors']['items.1']);
|
self::assertSame(
|
||||||
|
'object_id muss ein vorhandener Begriff einer öffentlichen Taxonomie sein.',
|
||||||
|
$plan['errors']['items.1']
|
||||||
|
);
|
||||||
self::assertSame([], $plan['warnings']);
|
self::assertSame([], $plan['warnings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +166,42 @@ final class MenuWriterTest extends TestCase
|
|||||||
self::assertSame(['items.1' => 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'], $plan['errors']);
|
self::assertSame(['items.1' => 'Ungültige url (erlaubt: /pfad, #anker, http(s), mailto, tel).'], $plan['errors']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{0: string}> */
|
||||||
|
public static function badUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mixed-case javascript' => ['JaVaScRiPt:alert(1)'],
|
||||||
|
'embedded tab' => ["java\tscript:alert(1)"],
|
||||||
|
'data' => ['data:text/html,<script>alert(1)</script>'],
|
||||||
|
'vbscript' => ['vbscript:msgbox(1)'],
|
||||||
|
'protocol-relative' => ['//evil.test/'],
|
||||||
|
'protocol-relative backslash' => ['/\\evil.test/'],
|
||||||
|
'leading space' => [' https://example.test/'],
|
||||||
|
'control character' => ["https://example.test/\x00"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider badUrls */
|
||||||
|
public function testDangerousUrlsAreRejected(string $url): void
|
||||||
|
{
|
||||||
|
$plan = (new MenuWriter())->plan([['type' => 'custom', 'url' => $url, 'title' => 'X']], []);
|
||||||
|
|
||||||
|
self::assertStringContainsString('url', $plan['errors']['items.0']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSafeUrlsAreAccepted(): void
|
||||||
|
{
|
||||||
|
$plan = (new MenuWriter())->plan([
|
||||||
|
['type' => 'custom', 'url' => '/kontakt', 'title' => 'A'],
|
||||||
|
['type' => 'custom', 'url' => '#', 'title' => 'B'],
|
||||||
|
['type' => 'custom', 'url' => 'HTTPS://example.test/', 'title' => 'C'],
|
||||||
|
['type' => 'custom', 'url' => 'mailto:info@example.test', 'title' => 'D'],
|
||||||
|
['type' => 'custom', 'url' => 'tel:+4912345', 'title' => 'E'],
|
||||||
|
], []);
|
||||||
|
|
||||||
|
self::assertSame([], $plan['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testIdZeroMeansANewItem(): void
|
public function testIdZeroMeansANewItem(): void
|
||||||
{
|
{
|
||||||
$plan = (new MenuWriter())->plan([['id' => 0, 'type' => 'custom', 'url' => '#', 'title' => 'Mehr']], []);
|
$plan = (new MenuWriter())->plan([['id' => 0, 'type' => 'custom', 'url' => '#', 'title' => 'Mehr']], []);
|
||||||
@@ -160,22 +236,34 @@ final class MenuWriterTest extends TestCase
|
|||||||
public function testMenuGetOutputRoundTripsAsANoOp(): void
|
public function testMenuGetOutputRoundTripsAsANoOp(): void
|
||||||
{
|
{
|
||||||
Functions\expect('get_post')->never();
|
Functions\expect('get_post')->never();
|
||||||
|
Functions\expect('wp_update_nav_menu_item')->never();
|
||||||
|
Functions\expect('wp_delete_post')->never();
|
||||||
$current = $this->realistic();
|
$current = $this->realistic();
|
||||||
$tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true);
|
$tree = json_decode((string) json_encode(MenuTree::build(array_values($current))), true);
|
||||||
$calls = [];
|
|
||||||
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
|
||||||
$calls[$id] = $args;
|
|
||||||
return $id;
|
|
||||||
});
|
|
||||||
Functions\expect('wp_delete_post')->never();
|
|
||||||
|
|
||||||
$writer = new MenuWriter();
|
$writer = new MenuWriter();
|
||||||
$plan = $writer->plan($tree, $current);
|
$plan = $writer->plan($tree, $current);
|
||||||
$stats = $writer->apply(3, $tree, $current);
|
$stats = $writer->apply(3, $tree, $current);
|
||||||
|
|
||||||
self::assertSame(['errors' => [], 'removals' => [], 'warnings' => []], $plan);
|
self::assertSame(['errors' => [], 'removals' => [], 'warnings' => []], $plan);
|
||||||
|
self::assertSame(['created' => 0, 'updated' => 0, 'removed' => 0], $stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyMovedItemsAreRewrittenAndKeepTheirData(): void
|
||||||
|
{
|
||||||
|
$current = $this->realistic();
|
||||||
|
$calls = [];
|
||||||
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
||||||
|
$calls[$id] = $args;
|
||||||
|
return $id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kontakt(13) moves to the front: every item's position shifts by one.
|
||||||
|
$tree = MenuTree::build(array_values($current));
|
||||||
|
array_unshift($tree, array_pop($tree));
|
||||||
|
$stats = (new MenuWriter())->apply(3, $tree, $current);
|
||||||
|
|
||||||
self::assertSame(['created' => 0, 'updated' => 4, 'removed' => 0], $stats);
|
self::assertSame(['created' => 0, 'updated' => 4, 'removed' => 0], $stats);
|
||||||
self::assertSame([10, 11, 12, 13], array_keys($calls));
|
|
||||||
foreach ($current as $id => $item) {
|
foreach ($current as $id => $item) {
|
||||||
self::assertSame($item->post_title, $calls[$id]['menu-item-title'], "title of $id");
|
self::assertSame($item->post_title, $calls[$id]['menu-item-title'], "title of $id");
|
||||||
self::assertSame((int) $item->menu_item_parent, $calls[$id]['menu-item-parent-id'], "parent of $id");
|
self::assertSame((int) $item->menu_item_parent, $calls[$id]['menu-item-parent-id'], "parent of $id");
|
||||||
@@ -183,7 +271,27 @@ final class MenuWriterTest extends TestCase
|
|||||||
}
|
}
|
||||||
self::assertSame('', $calls[10]['menu-item-url']);
|
self::assertSame('', $calls[10]['menu-item-url']);
|
||||||
self::assertSame('/kontakt', $calls[13]['menu-item-url']);
|
self::assertSame('/kontakt', $calls[13]['menu-item-url']);
|
||||||
self::assertSame([1, 2, 3, 4], array_column($calls, 'menu-item-position'));
|
self::assertSame([1, 2, 3, 4], [$calls[13]['menu-item-position'], $calls[10]['menu-item-position'],
|
||||||
|
$calls[11]['menu-item-position'], $calls[12]['menu-item-position']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMovingOnlyTheLastItemUnderAnotherRewritesOnlyThatItem(): void
|
||||||
|
{
|
||||||
|
$current = $this->realistic();
|
||||||
|
$calls = [];
|
||||||
|
Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) {
|
||||||
|
$calls[$id] = $args;
|
||||||
|
return $id;
|
||||||
|
});
|
||||||
|
|
||||||
|
$stats = (new MenuWriter())->apply(3, [
|
||||||
|
['id' => 10],
|
||||||
|
['id' => 11, 'children' => [['id' => 12], ['id' => 13]]],
|
||||||
|
], $current);
|
||||||
|
|
||||||
|
self::assertSame(['created' => 0, 'updated' => 1, 'removed' => 0], $stats);
|
||||||
|
self::assertSame([13], array_keys($calls));
|
||||||
|
self::assertSame(11, $calls[13]['menu-item-parent-id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testChangedTitleOfAnExistingItemBecomesItsLabel(): void
|
public function testChangedTitleOfAnExistingItemBecomesItsLabel(): void
|
||||||
@@ -201,8 +309,8 @@ final class MenuWriterTest extends TestCase
|
|||||||
['id' => 13, 'title' => 'Kontakt', 'url' => 'mailto:info@example.test'],
|
['id' => 13, 'title' => 'Kontakt', 'url' => 'mailto:info@example.test'],
|
||||||
], $current);
|
], $current);
|
||||||
|
|
||||||
|
self::assertSame([10, 13], array_keys($calls));
|
||||||
self::assertSame('Startseite', $calls[10]['menu-item-title']);
|
self::assertSame('Startseite', $calls[10]['menu-item-title']);
|
||||||
self::assertSame('Wir über uns', $calls[11]['menu-item-title']);
|
|
||||||
self::assertSame('mailto:info@example.test', $calls[13]['menu-item-url']);
|
self::assertSame('mailto:info@example.test', $calls[13]['menu-item-url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user