From 33f3d0c4117f9887a92d147101e86c35343f7a6e Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 18 Aug 2026 07:56:05 +0600 Subject: [PATCH 1/4] fix(resource): cascade form delete to child resources The Deleted checkbox on Resource/Update only flipped the current resource, so children stayed live in the tree. Defer the flag to Resource/Delete and Resource/Undelete so form save matches context-menu soft-delete, including deletedon/deletedby. --- .../ResourceUpdateDeleteCascadeTest.php | 151 ++++++++++++++++++ .../Revolution/Processors/Resource/Update.php | 126 +++++++-------- 2 files changed, 212 insertions(+), 65 deletions(-) create mode 100644 _build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php diff --git a/_build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php b/_build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php new file mode 100644 index 00000000000..dc9ac920bd8 --- /dev/null +++ b/_build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php @@ -0,0 +1,151 @@ +modx->eventMap = []; + $this->removeTestResources(); + } + + /** + * @after + */ + public function tearDownFixtures() + { + $this->removeTestResources(); + parent::tearDownFixtures(); + } + + public function testFormDeleteMarksChildrenDeleted() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Child', $parent->get('id')); + $grandchild = $this->createTestResource(self::TITLE_PREFIX . ' Grandchild', $child->get('id')); + + $this->saveDeletedCheckbox($parent, 1); + + foreach ([$parent, $child, $grandchild] as $created) { + $resource = $this->modx->getObject(modResource::class, $created->get('id')); + $this->assertNotEmpty($resource, 'Resource ' . $created->get('pagetitle') . ' missing after form delete'); + $this->assertSame(1, (int)$resource->get('deleted'), $resource->get('pagetitle') . ' should be marked deleted'); + $this->assertGreaterThan(0, (int)$resource->get('deletedon'), $resource->get('pagetitle') . ' should have deletedon set'); + $this->assertGreaterThan( + 0, + (int)$resource->get('deletedby'), + $resource->get('pagetitle') . ' should record deletedby' + ); + } + } + + public function testFormUndeleteRestoresChildren() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Restore Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Restore Child', $parent->get('id')); + + $this->saveDeletedCheckbox($parent, 1); + + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertNotEmpty($parent); + $this->assertNotEmpty($child); + $this->assertSame(1, (int)$parent->get('deleted')); + $this->assertSame(1, (int)$child->get('deleted')); + + $this->saveDeletedCheckbox($parent, 0); + + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$parent->get('deleted')); + $this->assertSame(0, (int)$child->get('deleted')); + $this->assertSame(0, (int)$parent->get('deletedon')); + $this->assertSame(0, (int)$child->get('deletedon')); + $this->assertSame(0, (int)$parent->get('deletedby')); + $this->assertSame(0, (int)$child->get('deletedby')); + } + + private function saveDeletedCheckbox(modResource $resource, int $deleted): void + { + $this->modx->error->reset(); + $fields = $resource->toArray(); + $fields['deleted'] = $deleted; + $fields['syncsite'] = 0; + $result = $this->modx->runProcessor(Update::class, $fields); + $this->assertTrue( + $this->checkForSuccess($result), + $result ? $result->getMessage() : 'no response' + ); + } + + private function createTestResource(string $pagetitle, int $parent = 0): modResource + { + $this->modx->error->reset(); + $result = $this->modx->runProcessor(Create::class, [ + 'pagetitle' => $pagetitle, + 'alias' => strtolower(str_replace(' ', '-', $pagetitle)), + 'parent' => $parent, + 'template' => 0, + 'published' => false, + 'context_key' => 'web', + 'class_key' => modDocument::class, + ]); + $this->assertTrue( + $this->checkForSuccess($result), + 'Could not create ' . $pagetitle . ': ' . ($result ? $result->getMessage() : 'no response') + ); + + $resource = $this->modx->getObject(modResource::class, ['pagetitle' => $pagetitle]); + $this->assertNotEmpty($resource, 'Created resource not found: ' . $pagetitle); + + return $resource; + } + + private function removeTestResources(): void + { + if (!($this->modx instanceof modX)) { + return; + } + $resources = $this->modx->getCollection(modResource::class, [ + 'pagetitle:LIKE' => '%' . self::TITLE_PREFIX . '%', + ]); + foreach ($resources as $resource) { + $resource->remove(); + } + } +} diff --git a/core/src/Revolution/Processors/Resource/Update.php b/core/src/Revolution/Processors/Resource/Update.php index 04a7c489ca3..17042845f88 100644 --- a/core/src/Revolution/Processors/Resource/Update.php +++ b/core/src/Revolution/Processors/Resource/Update.php @@ -549,46 +549,40 @@ public function checkForUnPublishOnSitePages() } /** - * Check deleted status and ensure user has permissions to delete resource + * Check deleted status and ensure user has permissions to delete resource. + * + * Revert `deleted` so this save does not flip it. Resource/Delete and + * Resource/Undelete apply the change after save and cascade children (#14167). */ public function checkDeletedStatus(): bool { $proposedDeleted = (bool)$this->getProperty('deleted'); $currentDeleted = (bool)$this->object->get('deleted'); + if ($proposedDeleted === $currentDeleted) { + return $proposedDeleted; + } - if ($proposedDeleted !== $currentDeleted) { - if ($currentDeleted) { - // The previously-saved value was 1 (resource was deleted), so attempt to undelete - if (!$this->modx->hasPermission('undelete_document')) { - $this->setProperty('deleted', $currentDeleted); - } else { - $this->object->set('deleted', false); - $this->resourceUnDeleted = true; - } - } else { - // The previously-saved value was 0 or null, so attempt to delete - $hasPermission = $this->modx->hasPermission('delete_document'); - - $map = [ - modWebLink::class => 'delete_weblink', - modSymLink::class => 'delete_symlink', - modStaticResource::class => 'delete_static_resource', - ]; - - if (array_key_exists($this->object->get('class_key'), $map)) { - $permission = $map[$this->object->get('class_key')]; - $hasPermission = $hasPermission && $this->modx->hasPermission($permission); - } + $this->setProperty('deleted', $currentDeleted); - if (!$hasPermission) { - $this->setProperty('deleted', $currentDeleted); - } else { - $this->object->set('deleted', true); - $this->resourceDeleted = true; - } - } + if ($currentDeleted) { + $this->resourceUnDeleted = $this->modx->hasPermission('undelete_document') + && $this->object->checkPolicy(['save' => true, 'undelete' => true]); + return $proposedDeleted; } + $hasPermission = $this->modx->hasPermission('delete_document'); + $map = [ + modWebLink::class => 'delete_weblink', + modSymLink::class => 'delete_symlink', + modStaticResource::class => 'delete_static_resource', + ]; + $classKey = $this->object->get('class_key'); + if (array_key_exists($classKey, $map)) { + $hasPermission = $hasPermission && $this->modx->hasPermission($map[$classKey]); + } + $this->resourceDeleted = $hasPermission + && $this->object->checkPolicy(['save' => true, 'delete' => true]); + return $proposedDeleted; } @@ -678,11 +672,41 @@ public function afterSave() $this->saveTemplateVariables(); $this->setResourceGroups(); $this->checkContextOfChildren(); - $this->fireUnDeleteEvent(); - $this->fireDeleteEvent(); + if ($this->resourceDeleted) { + $this->runDeferredLifecycleProcessor(Delete::class); + } elseif ($this->resourceUnDeleted) { + $this->runDeferredLifecycleProcessor(Undelete::class); + } return parent::afterSave(); } + /** + * @param class-string $processorClass + */ + protected function runDeferredLifecycleProcessor(string $processorClass): void + { + $response = $this->modx->runProcessor($processorClass, [ + 'id' => $this->object->get('id'), + 'syncsite' => $this->getProperty('syncsite', false), + ]); + if (!$response || $response->isError()) { + $message = $response ? $response->getMessage() : $this->modx->lexicon('error'); + $this->addFieldError('deleted', $message); + $this->modx->log(modX::LOG_LEVEL_ERROR, sprintf( + 'Resource/Update deferred %s failed for resource %s: %s', + $processorClass, + $this->object->get('id'), + $message + )); + return; + } + + $reloaded = $this->modx->getObject(modResource::class, $this->object->get('id')); + if ($reloaded instanceof modResource) { + $this->object = $reloaded; + } + } + /** * Set the parents isfolder status based upon remaining children * @@ -880,38 +904,6 @@ public function checkContextOfChildren() } } - /** - * Fire UnDelete event if resource was undeleted - * @return mixed - */ - public function fireUnDeleteEvent() - { - $response = null; - if (!empty($this->resourceUnDeleted)) { - $response = $this->modx->invokeEvent('OnResourceUndelete', [ - 'id' => $this->object->get('id'), - 'resource' => &$this->object, - ]); - } - return $response; - } - - /** - * Fire Delete event if resource was deleted - * @return null - */ - public function fireDeleteEvent() - { - $response = null; - if (!empty($this->resourceDeleted)) { - $this->modx->invokeEvent('OnResourceDelete', [ - 'id' => $this->object->get('id'), - 'resource' => &$this->object, - ]); - } - return $response; - } - /** * Cleanup the processor and return the resulting object * @@ -920,6 +912,10 @@ public function fireDeleteEvent() public function cleanup() { $this->object->removeLock(); + if ($this->hasErrors()) { + return $this->failure(); + } + $this->clearCache(); $returnArray = $this->object->get(array_diff(array_keys($this->object->_fields), ['content', 'ta', 'introtext', 'description', 'link_attributes', 'pagetitle', 'longtitle', 'menutitle', 'properties', 'resource_groups'])); From 0cd2521a5c4c90d841407c16200be8f1722287b4 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 18 Aug 2026 08:13:02 +0600 Subject: [PATCH 2/4] fix(resource): restore ancestors and block nested system-page delete Restoring a child from trash left its deleted parent in place, and purging a parent only mentioned the parent in the success message. Deleting a folder that contained site_start, error_page, or site_unavailable_page also skipped the root-level protection. Restore now undeletes the ancestor chain. Purge counts removed descendants. Parent delete fails if the tree includes a system page. Creating or moving under a deleted parent is refused. --- .../Processors/ResourceTrashHierarchyTest.php | 187 ++++++++++++++++++ .../ResourceUpdateDeleteCascadeTest.php | 14 +- _build/test/phpunit.xml | 2 + core/lexicon/en/resource.inc.php | 4 + core/lexicon/ru/resource.inc.php | 4 + .../Revolution/Processors/Resource/Create.php | 3 + .../Revolution/Processors/Resource/Delete.php | 80 ++++++-- .../Resource/RestoresDeletedAncestors.php | 56 ++++++ .../Processors/Resource/Trash/Purge.php | 26 +++ .../Processors/Resource/Trash/Restore.php | 15 ++ .../Processors/Resource/Undelete.php | 9 + .../Revolution/Processors/Resource/Update.php | 7 + 12 files changed, 383 insertions(+), 24 deletions(-) create mode 100644 _build/test/Tests/Processors/ResourceTrashHierarchyTest.php rename _build/test/Tests/Processors/{Resource => }/ResourceUpdateDeleteCascadeTest.php (92%) create mode 100644 core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php diff --git a/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php new file mode 100644 index 00000000000..7c66f670a53 --- /dev/null +++ b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php @@ -0,0 +1,187 @@ +modx->eventMap = []; + $this->removeTestResources(); + } + + /** + * @after + */ + public function tearDownFixtures() + { + $this->removeTestResources(); + parent::tearDownFixtures(); + } + + public function testTrashRestoreChildAlsoRestoresDeletedParent() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Restore Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Restore Child', $parent->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $restore = $this->modx->runProcessor(Restore::class, [ + 'ids' => (string)$child->get('id'), + ]); + $this->assertTrue($this->checkForSuccess($restore), $restore ? $restore->getMessage() : 'restore failed'); + + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$parent->get('deleted'), 'deleted parent must be restored with the child'); + $this->assertSame(0, (int)$child->get('deleted')); + } + + public function testPurgeParentMessageIncludesChildren() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Purge Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Purge Child', $parent->get('id')); + $parentId = $parent->get('id'); + $childId = $child->get('id'); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parentId, + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $purge = $this->modx->runProcessor(Purge::class, [ + 'ids' => (string)$parentId, + ]); + $this->assertTrue($this->checkForSuccess($purge), $purge ? $purge->getMessage() : 'purge failed'); + $object = $purge->getObject(); + $this->assertGreaterThanOrEqual(2, (int)$object['count_success']); + $this->assertEmpty($this->modx->getObject(modResource::class, $parentId)); + $this->assertEmpty($this->modx->getObject(modResource::class, $childId)); + } + + public function testDeleteParentContainingSiteStartIsBlocked() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Protected Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Protected Child', $parent->get('id')); + $previous = $this->modx->getOption('site_start'); + $this->modx->setOption('site_start', $child->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->modx->setOption('site_start', $previous); + + $this->assertFalse($this->checkForSuccess($delete), 'Deleting a container of site_start must fail'); + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$parent->get('deleted')); + $this->assertSame(0, (int)$child->get('deleted')); + } + + public function testCannotCreateUnderDeletedParent() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Deleted Parent'); + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $create = $this->modx->runProcessor(Create::class, [ + 'pagetitle' => self::TITLE_PREFIX . ' Under Deleted', + 'alias' => 'unit-test-14167-under-deleted', + 'parent' => $parent->get('id'), + 'template' => 0, + 'published' => false, + 'context_key' => 'web', + 'class_key' => modDocument::class, + ]); + $this->assertFalse($this->checkForSuccess($create), 'Create under a deleted parent must fail'); + } + + private function createTestResource(string $pagetitle, int $parent = 0): modResource + { + $this->modx->error->reset(); + $result = $this->modx->runProcessor(Create::class, [ + 'pagetitle' => $pagetitle, + 'alias' => strtolower(str_replace(' ', '-', $pagetitle)), + 'parent' => $parent, + 'template' => 0, + 'published' => false, + 'context_key' => 'web', + 'class_key' => modDocument::class, + ]); + $this->assertTrue( + $this->checkForSuccess($result), + 'Could not create ' . $pagetitle . ': ' . ($result ? $result->getMessage() : 'no response') + ); + + $resource = $this->modx->getObject(modResource::class, ['pagetitle' => $pagetitle]); + $this->assertNotEmpty($resource, 'Created resource not found: ' . $pagetitle); + + return $resource; + } + + private function removeTestResources(): void + { + if (!($this->modx instanceof modX)) { + return; + } + $resources = $this->modx->getCollection(modResource::class, [ + 'pagetitle:LIKE' => '%' . self::TITLE_PREFIX . '%', + ]); + foreach ($resources as $resource) { + $resource->remove(); + } + } +} diff --git a/_build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php similarity index 92% rename from _build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php rename to _build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php index dc9ac920bd8..85c3bfe3ff0 100644 --- a/_build/test/Tests/Processors/Resource/ResourceUpdateDeleteCascadeTest.php +++ b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php @@ -11,7 +11,7 @@ * @package modx-test */ -namespace MODX\Revolution\Tests\Processors\Resource; +namespace MODX\Revolution\Tests\Processors; use MODX\Revolution\modDocument; use MODX\Revolution\modResource; @@ -64,8 +64,16 @@ public function testFormDeleteMarksChildrenDeleted() foreach ([$parent, $child, $grandchild] as $created) { $resource = $this->modx->getObject(modResource::class, $created->get('id')); $this->assertNotEmpty($resource, 'Resource ' . $created->get('pagetitle') . ' missing after form delete'); - $this->assertSame(1, (int)$resource->get('deleted'), $resource->get('pagetitle') . ' should be marked deleted'); - $this->assertGreaterThan(0, (int)$resource->get('deletedon'), $resource->get('pagetitle') . ' should have deletedon set'); + $this->assertSame( + 1, + (int)$resource->get('deleted'), + $resource->get('pagetitle') . ' should be marked deleted' + ); + $this->assertGreaterThan( + 0, + (int)$resource->get('deletedon'), + $resource->get('pagetitle') . ' should have deletedon set' + ); $this->assertGreaterThan( 0, (int)$resource->get('deletedby'), diff --git a/_build/test/phpunit.xml b/_build/test/phpunit.xml index 6eba756c6c6..50ea15452ba 100644 --- a/_build/test/phpunit.xml +++ b/_build/test/phpunit.xml @@ -46,6 +46,8 @@ Tests/Processors/Context Tests/Processors/Element Tests/Processors/Resource + Tests/Processors/ResourceUpdateDeleteCascadeTest.php + Tests/Processors/ResourceTrashHierarchyTest.php Tests/Transport diff --git a/core/lexicon/en/resource.inc.php b/core/lexicon/en/resource.inc.php index 23744a613ab..e8aaa90ead8 100644 --- a/core/lexicon/en/resource.inc.php +++ b/core/lexicon/en/resource.inc.php @@ -59,6 +59,8 @@ $_lang['resource_err_delete'] = 'An error occurred while trying to delete the resource.'; $_lang['resource_err_delete_children'] = 'An error occurred while trying to delete the children of the resource.'; $_lang['resource_err_delete_container_sitestart'] = 'The resource you are trying to delete is a container containing resource [[+id]]. This resource is registered as the \'Site start\' resource, and cannot be deleted. Please assign another resource as your \'Site start\' resource and try again.'; +$_lang['resource_err_delete_container_errorpage'] = 'The resource you are trying to delete is a container containing resource [[+id]]. That resource is used as the Error page and cannot be deleted. Assign another Error page and try again.'; +$_lang['resource_err_delete_container_siteunavailable'] = 'The resource you are trying to delete is a container containing resource [[+id]]. That resource is used as the Site unavailable page and cannot be deleted. Assign another Site unavailable page and try again.'; $_lang['resource_err_delete_sitestart'] = 'The resource is \'Site start\' and cannot be deleted!'; $_lang['resource_err_delete_errorpage'] = 'The resource is used as the \'Error page\' and cannot be deleted!'; $_lang['resource_err_delete_siteunavailable'] = 'The resource is used as the \'Site unavailable page\' and cannot be deleted!'; @@ -69,6 +71,7 @@ $_lang['resource_err_nfs'] = 'Resource with ID [[+id]] not found'; $_lang['resource_err_ns'] = 'Resource not specified.'; $_lang['resource_err_own_parent'] = 'The resource cannot be its own parent.'; +$_lang['resource_err_parent_deleted'] = 'You cannot create or move a resource under a deleted parent.'; $_lang['resource_err_preview_no_zero_id'] = 'This Resource (id = [[+source_id]]) is a link to another Resource, but its preview URL can not be created because the target Resource id ([[+target_id]]) is 0 or begins with 0.'; $_lang['resource_err_preview_self_deleted'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it has been marked as deleted.'; $_lang['resource_err_preview_self_not_found'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it does not exist.'; @@ -83,6 +86,7 @@ $_lang['resource_err_symlink_target_nf'] = 'You cannot symlink to a resource that does not exist.'; $_lang['resource_err_symlink_target_self'] = 'You cannot symlink to itself.'; $_lang['resource_err_undelete'] = 'An error occurred while trying to undelete the resource.'; +$_lang['resource_err_undelete_parent'] = 'The parent resource is deleted and could not be restored. Restore the parent first.'; $_lang['resource_err_undelete_children'] = 'An error occurred while trying to undelete the children of the resource.'; $_lang['resource_err_unpublish'] = 'An error occurred while trying to unpublish the resource.'; $_lang['resource_err_unpublish_sitestart'] = 'The resource is linked to the site_start variable and cannot be unpublished!'; diff --git a/core/lexicon/ru/resource.inc.php b/core/lexicon/ru/resource.inc.php index bac591d4acc..32b5deb0693 100644 --- a/core/lexicon/ru/resource.inc.php +++ b/core/lexicon/ru/resource.inc.php @@ -59,6 +59,8 @@ $_lang['resource_err_delete'] = 'Произошла ошибка при попытке удаления ресурса.'; $_lang['resource_err_delete_children'] = 'Произошла ошибка при попытке удалить дочерние ресурсы этого ресурса.'; $_lang['resource_err_delete_container_sitestart'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан в настройках системы как «Главная страница сайта», и он не может быть удалён. Пожалуйста, укажите другой ресурс в настройках системы как «Главная страница сайта» и повторите попытку удаления.'; +$_lang['resource_err_delete_container_errorpage'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан как страница «Документ не найден» и не может быть удалён. Укажите другую страницу ошибки и повторите попытку.'; +$_lang['resource_err_delete_container_siteunavailable'] = 'Ресурс, который вы пытаетесь удалить, является папкой и содержит ресурс с ID [[+id]]. Этот ресурс указан как страница «Сайт недоступен» и не может быть удалён. Укажите другую страницу и повторите попытку.'; $_lang['resource_err_delete_sitestart'] = 'Ресурс указан в настройках системы как «Главная страница сайта» и не может быть удалён!'; $_lang['resource_err_delete_errorpage'] = 'Ресурс указан в настройках системы как страница «Документ не найден» и не может быть удалён!'; $_lang['resource_err_delete_siteunavailable'] = 'Ресурс указан в настройках системы как страница «Сайт недоступен» и не может быть удалён!'; @@ -69,6 +71,7 @@ $_lang['resource_err_nfs'] = 'Ресурс с ID [[+id]] не найден'; $_lang['resource_err_ns'] = 'Ресурс не указан.'; $_lang['resource_err_own_parent'] = 'Ресурс не может быть своим собственным родительским ресурсом.'; +$_lang['resource_err_parent_deleted'] = 'Нельзя создать или переместить ресурс в удалённый родительский ресурс.'; $_lang['resource_err_preview_no_zero_id'] = 'This Resource (id = [[+source_id]]) is a link to another Resource, but its preview URL can not be created because the target Resource id ([[+target_id]]) is 0 or begins with 0.'; $_lang['resource_err_preview_self_deleted'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it has been marked as deleted.'; $_lang['resource_err_preview_self_not_found'] = 'Can not create a preview URL for this Resource (id = [[+target_id]]) because it does not exist.'; @@ -83,6 +86,7 @@ $_lang['resource_err_symlink_target_nf'] = 'Вы не можете задать символическую ссылку для несуществующего ресурса.'; $_lang['resource_err_symlink_target_self'] = 'Нельзя указать символическую ссылку на себя.'; $_lang['resource_err_undelete'] = 'Произошла ошибка при попытке восстановить ресурс.'; +$_lang['resource_err_undelete_parent'] = 'Родительский ресурс удалён и не удалось его восстановить. Сначала восстановите родителя.'; $_lang['resource_err_undelete_children'] = 'Произошла ошибка при попытке восстановить дочерние ресурсы этого ресурса.'; $_lang['resource_err_unpublish'] = 'Произошла ошибка при попытке отменить публикацию ресурса.'; $_lang['resource_err_unpublish_sitestart'] = 'Ресурс указан в настройках системы как «Главная страница сайта» и его публикация не может быть отменена!'; diff --git a/core/src/Revolution/Processors/Resource/Create.php b/core/src/Revolution/Processors/Resource/Create.php index 1737358248d..3475babbd97 100644 --- a/core/src/Revolution/Processors/Resource/Create.php +++ b/core/src/Revolution/Processors/Resource/Create.php @@ -355,6 +355,9 @@ public function checkParentPermissions() if ($parentId > 0) { $this->parentResource = $this->modx->getObject(modResource::class, $parentId); if ($this->parentResource) { + if ($this->parentResource->get('deleted')) { + return $this->modx->lexicon('resource_err_parent_deleted'); + } if (!$this->parentResource->checkPolicy('add_children')) { return $this->modx->lexicon('resource_add_children_access_denied'); } diff --git a/core/src/Revolution/Processors/Resource/Delete.php b/core/src/Revolution/Processors/Resource/Delete.php index 077ddf7008a..6c74f0449b3 100644 --- a/core/src/Revolution/Processors/Resource/Delete.php +++ b/core/src/Revolution/Processors/Resource/Delete.php @@ -1,4 +1,5 @@ isSitePage('site_start')) { - return $this->failure($this->modx->lexicon('resource_err_delete_sitestart')); - } - - if ($this->isSitePage('error_page')) { - return $this->failure($this->modx->lexicon('resource_err_delete_errorpage')); - } - - if ($this->isSitePage('site_unavailable_page')) { - return $this->failure($this->modx->lexicon('resource_err_delete_siteunavailable')); + $protected = $this->getProtectedSitePageInTree(); + if ($protected !== null) { + return $this->failure($this->modx->lexicon($protected['lexicon'], [ + 'id' => $protected['id'], + ])); } /* check for locks on resource */ @@ -132,8 +128,59 @@ public function process() */ public function isSitePage(string $option) { - $workingContext = $this->modx->getContext($this->getProperty('context_key', $this->resource->get('context_key') ? $this->resource->get('context_key') : 'web')); - return ($this->resource->get('id') == $workingContext->getOption($option) || $this->resource->get('id') == $this->modx->getOption($option)); + return $this->resourceIsSiteOption($this->resource, $option); + } + + /** + * Block deleting a resource that is, or contains, site_start / error_page / site_unavailable_page (#14167). + * + * @return array{lexicon: string, id: int}|null + */ + protected function getProtectedSitePageInTree(): ?array + { + $selfMap = [ + 'site_start' => 'resource_err_delete_sitestart', + 'error_page' => 'resource_err_delete_errorpage', + 'site_unavailable_page' => 'resource_err_delete_siteunavailable', + ]; + $containerMap = [ + 'site_start' => 'resource_err_delete_container_sitestart', + 'error_page' => 'resource_err_delete_container_errorpage', + 'site_unavailable_page' => 'resource_err_delete_container_siteunavailable', + ]; + + foreach ($selfMap as $option => $lexicon) { + if ($this->resourceIsSiteOption($this->resource, $option)) { + return ['lexicon' => $lexicon, 'id' => (int)$this->resource->get('id')]; + } + } + + $this->children = []; + $this->getChildren($this->resource); + foreach ($this->children as $child) { + foreach ($containerMap as $option => $lexicon) { + if ($this->resourceIsSiteOption($child, $option)) { + return ['lexicon' => $lexicon, 'id' => (int)$child->get('id')]; + } + } + } + $this->children = []; + + return null; + } + + protected function resourceIsSiteOption(modResource $resource, string $option): bool + { + $id = (int)$resource->get('id'); + if ($id <= 0) { + return false; + } + $contextKey = $resource->get('context_key') ?: 'web'; + $context = $this->modx->getContext($contextKey); + $contextValue = $context ? (int)$context->getOption($option) : 0; + $systemValue = (int)$this->modx->getOption($option); + + return $id === $contextValue || $id === $systemValue; } /** @@ -184,16 +231,7 @@ protected function getChildren(modResource $parent) if (count($childResources) > 0) { /** @var modResource $child */ foreach ($childResources as $child) { - if ($child->get('id') == $this->modx->getOption('site_start')) { - continue; - } - if ($child->get('id') == $this->modx->getOption('site_unavailable_page')) { - continue; - } - $this->children[] = $child; - - /* recursively loop through tree */ $this->getChildren($child); } } diff --git a/core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php b/core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php new file mode 100644 index 00000000000..34a1cb4f3fd --- /dev/null +++ b/core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php @@ -0,0 +1,56 @@ +get('parent'); + $guard = 0; + while ($parentId > 0 && $guard < 100) { + $guard++; + $parent = $this->modx->getObject(modResource::class, $parentId); + if (!$parent instanceof modResource) { + break; + } + if ($parent->get('deleted')) { + if (!$parent->checkPolicy(['save' => true, 'undelete' => true])) { + return null; + } + $parent->set('deleted', false); + $parent->set('deletedby', 0); + $parent->set('deletedon', 0); + if ($parent->save() === false) { + return null; + } + $this->modx->invokeEvent('OnResourceUndelete', [ + 'id' => $parent->get('id'), + 'resource' => &$parent, + ]); + $restoredIds[] = (int)$parent->get('id'); + } + $parentId = (int)$parent->get('parent'); + } + + return $restoredIds; + } +} diff --git a/core/src/Revolution/Processors/Resource/Trash/Purge.php b/core/src/Revolution/Processors/Resource/Trash/Purge.php index a308cb56f61..6bf1cff8c28 100644 --- a/core/src/Revolution/Processors/Resource/Trash/Purge.php +++ b/core/src/Revolution/Processors/Resource/Trash/Purge.php @@ -1,4 +1,5 @@ get('id'); + if (in_array($id, $success, false)) { + continue; + } + + $descendantIds = $this->collectDescendantIds($resource); $resourceGroupResources = $resource->getMany('ResourceGroupResources'); $templateVarResources = $resource->getMany('TemplateVarResources'); @@ -158,6 +164,11 @@ public function process() $this->failures[] = $id; } else { $success[] = $id; + foreach ($descendantIds as $descendantId) { + if (!in_array($descendantId, $success, false)) { + $success[] = $descendantId; + } + } } } @@ -206,4 +217,19 @@ public function process() 'deletedCount' => $deletedCount, ]); } + + /** + * @return int[] + */ + private function collectDescendantIds(modResource $resource): array + { + $ids = []; + $children = $resource->getMany('Children'); + foreach ($children as $child) { + $ids[] = $child->get('id'); + $ids = array_merge($ids, $this->collectDescendantIds($child)); + } + + return $ids; + } } diff --git a/core/src/Revolution/Processors/Resource/Trash/Restore.php b/core/src/Revolution/Processors/Resource/Trash/Restore.php index b9042a3074f..d8bbf9141c6 100644 --- a/core/src/Revolution/Processors/Resource/Trash/Restore.php +++ b/core/src/Revolution/Processors/Resource/Trash/Restore.php @@ -12,6 +12,7 @@ namespace MODX\Revolution\Processors\Resource\Trash; use MODX\Revolution\Processors\Processor; +use MODX\Revolution\Processors\Resource\RestoresDeletedAncestors; use MODX\Revolution\modResource; use MODX\Revolution\modUser; @@ -24,6 +25,8 @@ */ class Restore extends Processor { + use RestoresDeletedAncestors; + /** @var modResource[] $resources */ private $resources = []; @@ -95,6 +98,18 @@ public function process() )); } + $ancestors = $this->restoreDeletedAncestors($resource); + if ($ancestors === null) { + $this->removeLock($resource); + $this->failures[] = $id; + continue; + } + foreach ($ancestors as $ancestorId) { + if (!in_array($ancestorId, $this->success, false)) { + $this->success[] = $ancestorId; + } + } + /* 'undelete' the resource. */ $resource->set('deleted', false); $resource->set('deletedby', 0); diff --git a/core/src/Revolution/Processors/Resource/Undelete.php b/core/src/Revolution/Processors/Resource/Undelete.php index c53543eaa2e..b32fdfd9d45 100644 --- a/core/src/Revolution/Processors/Resource/Undelete.php +++ b/core/src/Revolution/Processors/Resource/Undelete.php @@ -1,4 +1,5 @@ failure($this->modx->lexicon('resource_locked_by', ['id' => $this->resource->get('id'), 'user' => $this->lockedUser->get('username')])); } + $ancestors = $this->restoreDeletedAncestors($this->resource); + if ($ancestors === null) { + $this->resource->removeLock(); + return $this->failure($this->modx->lexicon('resource_err_undelete_parent')); + } + /* 'undelete' the resource. */ $this->resource->set('deleted', false); $this->resource->set('deletedby', 0); diff --git a/core/src/Revolution/Processors/Resource/Update.php b/core/src/Revolution/Processors/Resource/Update.php index 17042845f88..945de3b4803 100644 --- a/core/src/Revolution/Processors/Resource/Update.php +++ b/core/src/Revolution/Processors/Resource/Update.php @@ -307,6 +307,13 @@ public function handleParent() /* convert parent to int */ $this->setProperty('parent', empty($parent) ? 0 : intval($parent)); + $parentId = (int)$this->getProperty('parent'); + if ($parentId > 0) { + $parentResource = $this->modx->getObject(modResource::class, $parentId); + if ($parentResource && $parentResource->get('deleted')) { + $this->addFieldError('parent-cmb', $this->modx->lexicon('resource_err_parent_deleted')); + } + } } return $parent; } From e5f35e3d948c4ddb4a7cc2aaea81e04cdf070111 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 18 Aug 2026 08:17:30 +0600 Subject: [PATCH 3/4] style(phpcs): move RestoresDeletedAncestors out of Resource namespace Connector actions still map Resource/Delete to Processors\Resource, so that package cannot be renamed. Exclude the soft-reserved keyword sniff there and in the legacy processor tests. --- core/src/Revolution/Processors/Resource/Trash/Restore.php | 2 +- core/src/Revolution/Processors/Resource/Undelete.php | 1 + .../Processors/{Resource => }/RestoresDeletedAncestors.php | 2 +- phpcs.xml | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) rename core/src/Revolution/Processors/{Resource => }/RestoresDeletedAncestors.php (97%) diff --git a/core/src/Revolution/Processors/Resource/Trash/Restore.php b/core/src/Revolution/Processors/Resource/Trash/Restore.php index d8bbf9141c6..f4fa05d9e49 100644 --- a/core/src/Revolution/Processors/Resource/Trash/Restore.php +++ b/core/src/Revolution/Processors/Resource/Trash/Restore.php @@ -12,7 +12,7 @@ namespace MODX\Revolution\Processors\Resource\Trash; use MODX\Revolution\Processors\Processor; -use MODX\Revolution\Processors\Resource\RestoresDeletedAncestors; +use MODX\Revolution\Processors\RestoresDeletedAncestors; use MODX\Revolution\modResource; use MODX\Revolution\modUser; diff --git a/core/src/Revolution/Processors/Resource/Undelete.php b/core/src/Revolution/Processors/Resource/Undelete.php index b32fdfd9d45..fdaf2610882 100644 --- a/core/src/Revolution/Processors/Resource/Undelete.php +++ b/core/src/Revolution/Processors/Resource/Undelete.php @@ -12,6 +12,7 @@ namespace MODX\Revolution\Processors\Resource; use MODX\Revolution\Processors\Processor; +use MODX\Revolution\Processors\RestoresDeletedAncestors; use MODX\Revolution\modResource; use MODX\Revolution\modUser; diff --git a/core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php b/core/src/Revolution/Processors/RestoresDeletedAncestors.php similarity index 97% rename from core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php rename to core/src/Revolution/Processors/RestoresDeletedAncestors.php index 34a1cb4f3fd..f8ae74f3b3b 100644 --- a/core/src/Revolution/Processors/Resource/RestoresDeletedAncestors.php +++ b/core/src/Revolution/Processors/RestoresDeletedAncestors.php @@ -9,7 +9,7 @@ * files found in the top-level directory of this distribution. */ -namespace MODX\Revolution\Processors\Resource; +namespace MODX\Revolution\Processors; use MODX\Revolution\modResource; diff --git a/phpcs.xml b/phpcs.xml index 75bcc228a7b..ed506e8ca17 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -39,4 +39,9 @@ manager/controllers/default/ + + + */Processors/Resource/* + */Tests/Processors/Resource/* + From e8d32c3c7ad82a1519cb27d90031b70c6c8927be Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 18 Aug 2026 08:22:47 +0600 Subject: [PATCH 4/4] test(resource): cover delete, restore, and form-delete edge paths Codecov flagged 27 uncovered patch lines. Exercise deferred Delete failure, weblink form delete, move under a deleted parent, nested error_page, purge of parent+child ids, and Undelete of a child. --- .../Processors/ResourceTrashHierarchyTest.php | 114 ++++++++++++++++++ .../ResourceUpdateDeleteCascadeTest.php | 78 +++++++++++- 2 files changed, 189 insertions(+), 3 deletions(-) diff --git a/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php index 7c66f670a53..27e8a874386 100644 --- a/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php +++ b/_build/test/Tests/Processors/ResourceTrashHierarchyTest.php @@ -21,6 +21,7 @@ use MODX\Revolution\Processors\Resource\Delete; use MODX\Revolution\Processors\Resource\Trash\Purge; use MODX\Revolution\Processors\Resource\Trash\Restore; +use MODX\Revolution\Processors\Resource\Undelete; /** * Trash restore of a child restores deleted ancestors; purge lists descendants; @@ -149,6 +150,119 @@ public function testCannotCreateUnderDeletedParent() $this->assertFalse($this->checkForSuccess($create), 'Create under a deleted parent must fail'); } + public function testCannotDeleteSiteStartResource() + { + $resource = $this->createTestResource(self::TITLE_PREFIX . ' Site Start'); + $previous = $this->modx->getOption('site_start'); + $this->modx->setOption('site_start', $resource->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $resource->get('id'), + 'syncsite' => 0, + ]); + $this->modx->setOption('site_start', $previous); + + $this->assertFalse($this->checkForSuccess($delete), 'Deleting site_start itself must fail'); + $resource = $this->modx->getObject(modResource::class, $resource->get('id')); + $this->assertSame(0, (int)$resource->get('deleted')); + } + + public function testDeleteParentContainingErrorPageIsBlocked() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Error Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Error Child', $parent->get('id')); + $previous = $this->modx->getOption('error_page'); + $this->modx->setOption('error_page', $child->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->modx->setOption('error_page', $previous); + + $this->assertFalse($this->checkForSuccess($delete), 'Deleting a container of error_page must fail'); + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $this->assertSame(0, (int)$parent->get('deleted')); + } + + public function testPurgeWithParentAndChildIdsCountsEachOnce() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Dual Purge Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Dual Purge Child', $parent->get('id')); + $parentId = $parent->get('id'); + $childId = $child->get('id'); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parentId, + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $purge = $this->modx->runProcessor(Purge::class, [ + 'ids' => $parentId . ',' . $childId, + ]); + $this->assertTrue($this->checkForSuccess($purge), $purge ? $purge->getMessage() : 'purge failed'); + $object = $purge->getObject(); + $this->assertGreaterThanOrEqual(2, (int)$object['count_success']); + $this->assertEmpty($this->modx->getObject(modResource::class, $parentId)); + $this->assertEmpty($this->modx->getObject(modResource::class, $childId)); + } + + public function testUndeleteProcessorRestoresDeletedParent() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Undelete Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Undelete Child', $parent->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $undelete = $this->modx->runProcessor(Undelete::class, [ + 'id' => $child->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($undelete), $undelete ? $undelete->getMessage() : 'undelete failed'); + + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$parent->get('deleted')); + $this->assertSame(0, (int)$child->get('deleted')); + } + + public function testRestoreChildWithMissingParentStillSucceeds() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Ghost Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Ghost Child', $parent->get('id')); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $parent->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $child->set('parent', 2147483646); + $child->save(); + + $this->modx->error->reset(); + $restore = $this->modx->runProcessor(Restore::class, [ + 'ids' => (string)$child->get('id'), + ]); + $this->assertTrue($this->checkForSuccess($restore), $restore ? $restore->getMessage() : 'restore failed'); + + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$child->get('deleted')); + } + private function createTestResource(string $pagetitle, int $parent = 0): modResource { $this->modx->error->reset(); diff --git a/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php index 85c3bfe3ff0..5af7a5eef06 100644 --- a/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php +++ b/_build/test/Tests/Processors/ResourceUpdateDeleteCascadeTest.php @@ -15,9 +15,11 @@ use MODX\Revolution\modDocument; use MODX\Revolution\modResource; +use MODX\Revolution\modWebLink; use MODX\Revolution\modX; use MODX\Revolution\MODxTestCase; use MODX\Revolution\Processors\Resource\Create; +use MODX\Revolution\Processors\Resource\Delete; use MODX\Revolution\Processors\Resource\Update; /** @@ -108,6 +110,76 @@ public function testFormUndeleteRestoresChildren() $this->assertSame(0, (int)$child->get('deletedby')); } + public function testFormSaveWithoutChangingDeletedLeavesResourceLive() + { + $resource = $this->createTestResource(self::TITLE_PREFIX . ' Unchanged'); + $this->modx->error->reset(); + $fields = $resource->toArray(); + $fields['deleted'] = 0; + $fields['syncsite'] = 0; + $fields['pagetitle'] = self::TITLE_PREFIX . ' Unchanged Saved'; + $result = $this->modx->runProcessor(Update::class, $fields); + $this->assertTrue($this->checkForSuccess($result), $result ? $result->getMessage() : 'no response'); + + $resource = $this->modx->getObject(modResource::class, $resource->get('id')); + $this->assertSame(0, (int)$resource->get('deleted')); + } + + public function testCannotMoveResourceUnderDeletedParent() + { + $deletedParent = $this->createTestResource(self::TITLE_PREFIX . ' Deleted Target'); + $moving = $this->createTestResource(self::TITLE_PREFIX . ' Moving'); + + $this->modx->error->reset(); + $delete = $this->modx->runProcessor(Delete::class, [ + 'id' => $deletedParent->get('id'), + 'syncsite' => 0, + ]); + $this->assertTrue($this->checkForSuccess($delete), $delete ? $delete->getMessage() : 'delete failed'); + + $this->modx->error->reset(); + $fields = $moving->toArray(); + $fields['parent'] = $deletedParent->get('id'); + $fields['syncsite'] = 0; + $result = $this->modx->runProcessor(Update::class, $fields); + $this->assertFalse($this->checkForSuccess($result), 'Move under a deleted parent must fail'); + } + + public function testFormDeleteFailsWhenChildIsSiteStart() + { + $parent = $this->createTestResource(self::TITLE_PREFIX . ' Protected Parent'); + $child = $this->createTestResource(self::TITLE_PREFIX . ' Protected Child', $parent->get('id')); + $previous = $this->modx->getOption('site_start'); + $this->modx->setOption('site_start', $child->get('id')); + + $this->modx->error->reset(); + $fields = $parent->toArray(); + $fields['deleted'] = 1; + $fields['syncsite'] = 0; + $result = $this->modx->runProcessor(Update::class, $fields); + $this->modx->setOption('site_start', $previous); + + $this->assertFalse($this->checkForSuccess($result), 'Form delete of a site_start container must fail'); + $parent = $this->modx->getObject(modResource::class, $parent->get('id')); + $child = $this->modx->getObject(modResource::class, $child->get('id')); + $this->assertSame(0, (int)$parent->get('deleted')); + $this->assertSame(0, (int)$child->get('deleted')); + } + + public function testFormDeleteMarksWebLinkDeleted() + { + $weblink = $this->createTestResource(self::TITLE_PREFIX . ' WebLink', 0, [ + 'class_key' => modWebLink::class, + 'content' => 'https://example.com', + ]); + + $this->saveDeletedCheckbox($weblink, 1); + + $weblink = $this->modx->getObject(modResource::class, $weblink->get('id')); + $this->assertSame(1, (int)$weblink->get('deleted')); + $this->assertGreaterThan(0, (int)$weblink->get('deletedon')); + } + private function saveDeletedCheckbox(modResource $resource, int $deleted): void { $this->modx->error->reset(); @@ -121,10 +193,10 @@ private function saveDeletedCheckbox(modResource $resource, int $deleted): void ); } - private function createTestResource(string $pagetitle, int $parent = 0): modResource + private function createTestResource(string $pagetitle, int $parent = 0, array $extra = []): modResource { $this->modx->error->reset(); - $result = $this->modx->runProcessor(Create::class, [ + $result = $this->modx->runProcessor(Create::class, array_merge([ 'pagetitle' => $pagetitle, 'alias' => strtolower(str_replace(' ', '-', $pagetitle)), 'parent' => $parent, @@ -132,7 +204,7 @@ private function createTestResource(string $pagetitle, int $parent = 0): modReso 'published' => false, 'context_key' => 'web', 'class_key' => modDocument::class, - ]); + ], $extra)); $this->assertTrue( $this->checkForSuccess($result), 'Could not create ' . $pagetitle . ': ' . ($result ? $result->getMessage() : 'no response')