From d8963f02621b7d1d5f495882a44834df361fdd2c Mon Sep 17 00:00:00 2001 From: Peter Mathis Date: Thu, 13 Feb 2025 08:45:31 +0100 Subject: [PATCH] Move linkintegrity Classic-UI templates/views to plone.app.layout Refs. https://github.com/plone/Products.CMFPlone/issues/3953 --- news/3953.breaking.1 | 2 + setup.py | 6 + .../layout/analytics/tests/test_doctests.py | 4 +- .../app/layout/controlpanels/configure.zcml | 17 ++ .../app/layout/controlpanels/linkintegrity.py | 76 +++++ .../linkintegrity_delete_confirmation_info.pt | 135 ++++++++ .../templates/linkintegrity_update.pt | 58 ++++ .../app/layout/globals/tests/test_context.py | 4 +- .../app/layout/globals/tests/test_layout.py | 6 +- .../app/layout/globals/tests/test_portal.py | 4 +- .../app/layout/globals/tests/test_tools.py | 4 +- .../layout/links/tests/test_canonical_url.py | 4 +- .../links/tests/test_favicon_viewlet.py | 4 +- .../app/layout/sitemap/tests/test_sitemap.py | 4 +- src/plone/app/layout/testing.py | 23 +- .../tests/robot/test_linkintegrity.robot | 132 ++++++++ .../app/layout/tests/test_confirm_view.py | 4 +- .../app/layout/tests/test_linkintegrity.py | 287 ++++++++++++++++++ src/plone/app/layout/tests/test_robot.py | 30 ++ src/plone/app/layout/tests/test_setup.py | 6 +- src/plone/app/layout/viewlets/tests/base.py | 8 +- .../layout/viewlets/tests/test_functional.py | 4 +- tox.ini | 2 + 23 files changed, 790 insertions(+), 34 deletions(-) create mode 100644 news/3953.breaking.1 create mode 100644 src/plone/app/layout/controlpanels/linkintegrity.py create mode 100644 src/plone/app/layout/controlpanels/templates/linkintegrity_delete_confirmation_info.pt create mode 100644 src/plone/app/layout/controlpanels/templates/linkintegrity_update.pt create mode 100644 src/plone/app/layout/tests/robot/test_linkintegrity.robot create mode 100644 src/plone/app/layout/tests/test_linkintegrity.py create mode 100644 src/plone/app/layout/tests/test_robot.py diff --git a/news/3953.breaking.1 b/news/3953.breaking.1 new file mode 100644 index 000000000..65a145bcd --- /dev/null +++ b/news/3953.breaking.1 @@ -0,0 +1,2 @@ +Move ``plone.app.linkintegrity`` template/views to ``plone.app.layout.views.linkintegrity`` +[petschki] diff --git a/setup.py b/setup.py index 9bc78815b..a7424451d 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ install_requires=[ "plone.app.content", "plone.app.dexterity", + "plone.app.linkintegrity", "plone.app.relationfield", "plone.app.uuid", "plone.app.viewletmanager >=1.2", @@ -61,13 +62,18 @@ extras_require=dict( test=[ "plone.app.contenttypes[test]", + "plone.app.linkintegrity", "plone.app.relationfield", + "plone.app.robotframework", "plone.app.testing", + "plone.app.textfield", "plone.browserlayer", "plone.dexterity", "plone.locking", "plone.testing", + "robotsuite", "z3c.relationfield", + "zc.relation", "zope.intid", ] ), diff --git a/src/plone/app/layout/analytics/tests/test_doctests.py b/src/plone/app/layout/analytics/tests/test_doctests.py index f7f61d790..83126702a 100644 --- a/src/plone/app/layout/analytics/tests/test_doctests.py +++ b/src/plone/app/layout/analytics/tests/test_doctests.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import FUNCTIONAL_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING from plone.testing import layered import doctest @@ -22,7 +22,7 @@ def test_suite(): test, optionflags=OPTIONFLAGS, ), - layer=FUNCTIONAL_TESTING, + layer=PLONE_APP_LAYOUT_FUNCTIONAL_TESTING, ) for test in normal_testfiles ] diff --git a/src/plone/app/layout/controlpanels/configure.zcml b/src/plone/app/layout/controlpanels/configure.zcml index ecacd150d..370d65c1b 100644 --- a/src/plone/app/layout/controlpanels/configure.zcml +++ b/src/plone/app/layout/controlpanels/configure.zcml @@ -4,4 +4,21 @@ xmlns:zcml="http://namespaces.zope.org/zcml" > + + + + diff --git a/src/plone/app/layout/controlpanels/linkintegrity.py b/src/plone/app/layout/controlpanels/linkintegrity.py new file mode 100644 index 000000000..e7cac3db8 --- /dev/null +++ b/src/plone/app/layout/controlpanels/linkintegrity.py @@ -0,0 +1,76 @@ +from Acquisition import aq_inner +from datetime import datetime +from datetime import timedelta +from plone.app.linkintegrity.browser.info import ( + DeleteConfirmationInfo as DeleteConfirmationInfoAPI, +) +from plone.app.linkintegrity.handlers import modifiedContent +from plone.base import PloneMessageFactory as _ +from Products.CMFCore.utils import getToolByName +from Products.Five import BrowserView +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from transaction import savepoint +from zExceptions import NotFound + +import logging + +logger = logging.getLogger(__name__) + + +class DeleteConfirmationInfo(DeleteConfirmationInfoAPI): + template = ViewPageTemplateFile( + "templates/linkintegrity_delete_confirmation_info.pt" + ) + + +class UpdateView(BrowserView): + """Iterate over all catalogued items and update linkintegrity-information.""" + + def __call__(self): + context = aq_inner(self.context) + request = aq_inner(self.request) + if "update" in request.form or "delete_all" in request.form: + starttime = datetime.now() + count = self.update() + duration = timedelta(seconds=(datetime.now() - starttime).seconds) + msg = _( + "linkintegrity_update_info", + default="Link integrity information updated for ${count} " + + "items in ${time} seconds.", + mapping={"count": count, "time": str(duration)}, + ) + IStatusMessage(request).add(msg, type="info") + msg = "Updated {} items in {} seconds".format( + count, + str(duration), + ) + logger.info(msg) + request.RESPONSE.redirect(getToolByName(context, "portal_url")()) + elif "cancel" in request.form: + msg = _("Update cancelled.") + IStatusMessage(request).add(msg, type="info") + request.RESPONSE.redirect(getToolByName(context, "portal_url")()) + else: + return self.index() + + def update(self): + catalog = getToolByName(self.context, "portal_catalog") + count = 0 + + for brain in catalog(): + try: + obj = brain.getObject() + except (AttributeError, NotFound, KeyError): + msg = "Catalog inconsistency: {0} not found!" + logger.error(msg.format(brain.getPath()), exc_info=1) + continue + try: + modifiedContent(obj, "dummy event parameter") + count += 1 + except Exception: + msg = "Error updating linkintegrity-info for {0}." + logger.error(msg.format(obj.absolute_url()), exc_info=1) + if count % 1000 == 0: + savepoint(optimistic=True) + return count diff --git a/src/plone/app/layout/controlpanels/templates/linkintegrity_delete_confirmation_info.pt b/src/plone/app/layout/controlpanels/templates/linkintegrity_delete_confirmation_info.pt new file mode 100644 index 000000000..0b938c068 --- /dev/null +++ b/src/plone/app/layout/controlpanels/templates/linkintegrity_delete_confirmation_info.pt @@ -0,0 +1,135 @@ + + + +

Potential link breakage

+ +
+ +

+ By deleting this item, you will break links that exist in the items listed + below. If this is indeed what you want to do, we recommend that you remove + these references first. +

+ +
+
+ + +
+

+ + This + + is referenced by the following items: + +

+
+ + + +
+ +
+ +

Deleting overview

+

+ + Number of selected, non-empty folders: + + +

+
    +
  • + + Following content within + + will also be deleted: +
    +
      +
    • + + +
    • +
    +
  • +
+ +
+ +

+ Would you like to delete it anyway? +

+ +
+ +
+ + + diff --git a/src/plone/app/layout/controlpanels/templates/linkintegrity_update.pt b/src/plone/app/layout/controlpanels/templates/linkintegrity_update.pt new file mode 100644 index 000000000..f099245cc --- /dev/null +++ b/src/plone/app/layout/controlpanels/templates/linkintegrity_update.pt @@ -0,0 +1,58 @@ + + + + + + + + + +

Update link integrity information

+ +
+ +

+ Clicking the below button will cause link integrity information to be + updated. This might take a while, especially for bigger sites... +

+ +
+ + +
+ +
+ +
+ + diff --git a/src/plone/app/layout/globals/tests/test_context.py b/src/plone/app/layout/globals/tests/test_context.py index 7afc2d620..89b04e5a3 100644 --- a/src/plone/app/layout/globals/tests/test_context.py +++ b/src/plone/app/layout/globals/tests/test_context.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing.helpers import logout @@ -16,7 +16,7 @@ class TestContextStateView(unittest.TestCase): """Ensure that the basic redirector setup is successful.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.app = self.layer["app"] diff --git a/src/plone/app/layout/globals/tests/test_layout.py b/src/plone/app/layout/globals/tests/test_layout.py index a0e8d3fae..632bb139a 100644 --- a/src/plone/app/layout/globals/tests/test_layout.py +++ b/src/plone/app/layout/globals/tests/test_layout.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing.helpers import logout @@ -17,7 +17,7 @@ class TestLayoutView(unittest.TestCase): """Tests the global layout view.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] @@ -196,7 +196,7 @@ def testBodyClassColMarker(self): class TestLayoutViewXHR(unittest.TestCase): """Tests the global layout view with it's XHR utilities.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/globals/tests/test_portal.py b/src/plone/app/layout/globals/tests/test_portal.py index c9adf6245..fea289eb3 100644 --- a/src/plone/app/layout/globals/tests/test_portal.py +++ b/src/plone/app/layout/globals/tests/test_portal.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from plone.app.testing.helpers import logout @@ -20,7 +20,7 @@ class TestPortalStateView(unittest.TestCase): """Ensure that the basic redirector setup is successful.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/globals/tests/test_tools.py b/src/plone/app/layout/globals/tests/test_tools.py index aeb464b28..702e228d0 100644 --- a/src/plone/app/layout/globals/tests/test_tools.py +++ b/src/plone/app/layout/globals/tests/test_tools.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from Products.CMFCore.utils import getToolByName import unittest @@ -7,7 +7,7 @@ class TestToolsView(unittest.TestCase): """Tests the global tools view.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/links/tests/test_canonical_url.py b/src/plone/app/layout/links/tests/test_canonical_url.py index b20ba6ac7..5579ab2ba 100644 --- a/src/plone/app/layout/links/tests/test_canonical_url.py +++ b/src/plone/app/layout/links/tests/test_canonical_url.py @@ -1,11 +1,11 @@ -from plone.app.layout.testing import FUNCTIONAL_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING from plone.testing.zope import Browser import unittest class ViewletTestCase(unittest.TestCase): - layer = FUNCTIONAL_TESTING + layer = PLONE_APP_LAYOUT_FUNCTIONAL_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/links/tests/test_favicon_viewlet.py b/src/plone/app/layout/links/tests/test_favicon_viewlet.py index c05c5d23e..c67234e06 100644 --- a/src/plone/app/layout/links/tests/test_favicon_viewlet.py +++ b/src/plone/app/layout/links/tests/test_favicon_viewlet.py @@ -1,5 +1,5 @@ from plone.app.layout.links.viewlets import FaviconViewlet -from plone.app.layout.testing import FUNCTIONAL_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING from plone.app.layout.viewlets.tests.base import ViewletsTestCase from plone.base.interfaces import ISiteSchema from plone.formwidget.namedfile.converter import b64encode_file @@ -8,7 +8,7 @@ class TestFaviconViewletView(ViewletsTestCase, FaviconViewlet): - layer = FUNCTIONAL_TESTING + layer = PLONE_APP_LAYOUT_FUNCTIONAL_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/sitemap/tests/test_sitemap.py b/src/plone/app/layout/sitemap/tests/test_sitemap.py index 4952cd081..1b2ccfc9b 100644 --- a/src/plone/app/layout/sitemap/tests/test_sitemap.py +++ b/src/plone/app/layout/sitemap/tests/test_sitemap.py @@ -1,7 +1,7 @@ from DateTime import DateTime from gzip import GzipFile from io import BytesIO -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.app.testing import login from plone.app.testing import logout from plone.app.testing import setRoles @@ -25,7 +25,7 @@ class SiteMapTestCase(unittest.TestCase): """base test case with convenience methods for all sitemap tests""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/testing.py b/src/plone/app/layout/testing.py index f9f56cbdb..6101bb857 100644 --- a/src/plone/app/layout/testing.py +++ b/src/plone/app/layout/testing.py @@ -1,13 +1,15 @@ from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE +from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE from plone.app.testing import applyProfile from plone.app.testing import FunctionalTesting from plone.app.testing import IntegrationTesting from plone.app.testing import PloneSandboxLayer from plone.app.testing import TEST_USER_ID from plone.base.utils import unrestricted_construct_instance +from plone.testing import zope -class Fixture(PloneSandboxLayer): +class PloneAppLayoutFixture(PloneSandboxLayer): defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) def setUpZope(self, app, configurationContext): @@ -27,12 +29,21 @@ def setUpPloneSite(self, portal): applyProfile(portal, "plone.app.layout:default") -FIXTURE = Fixture() -INTEGRATION_TESTING = IntegrationTesting( - bases=(FIXTURE,), +class PloneAppLayoutRemoteFixture(PloneAppLayoutFixture): + defaultBases = (REMOTE_LIBRARY_BUNDLE_FIXTURE,) + + +PLONE_APP_LAYOUT_FIXTURE = PloneAppLayoutFixture() +PLONE_APP_LAYOUT_INTEGRATION_TESTING = IntegrationTesting( + bases=(PLONE_APP_LAYOUT_FIXTURE,), name="plone.app.layout:Integration", ) -FUNCTIONAL_TESTING = FunctionalTesting( - bases=(FIXTURE,), +PLONE_APP_LAYOUT_FUNCTIONAL_TESTING = FunctionalTesting( + bases=(PLONE_APP_LAYOUT_FIXTURE,), name="plone.app.layout:Functional", ) +PLONE_APP_LAYOUT_ROBOT_FIXTURE = PloneAppLayoutRemoteFixture() +PLONE_APP_LAYOUT_ROBOT_TESTING = FunctionalTesting( + bases=(PLONE_APP_LAYOUT_ROBOT_FIXTURE, zope.WSGI_SERVER_FIXTURE), + name="plone.app.layout:Robot", +) diff --git a/src/plone/app/layout/tests/robot/test_linkintegrity.robot b/src/plone/app/layout/tests/robot/test_linkintegrity.robot new file mode 100644 index 000000000..65c6cf4b4 --- /dev/null +++ b/src/plone/app/layout/tests/robot/test_linkintegrity.robot @@ -0,0 +1,132 @@ +*** Settings *** + +Resource plone/app/robotframework/browser.robot +Resource Products/CMFPlone/tests/robot/keywords.robot + +Library Remote ${PLONE_URL}/RobotRemote + +Test Setup Run Keywords Plone test setup +Test Teardown Run keywords Plone test teardown + + +*** Test Cases *** + +Scenario: When page is linked show warning + Given a logged-in site administrator + and a page to link to + and a page to edit + When I add a link in rich text + Then I should see a warning when deleting page + +Scenario: After you fix linked page no longer show warning + Given a logged-in site administrator + and a page to link to + and a page to edit + When I add a link in rich text + Then I should see a warning when deleting page + + When I remove link to page + Then I should not see a warning when deleting page + +Scenario: Show warning when deleting linked item from folder_contents + Given a logged-in site administrator + and a page to link to + and a page to edit + When I add a link in rich text + Then I should see a warning when deleting page from folder_contents + + When I remove link to page + Then I should not see a warning when deleting page from folder_contents + +*** Keywords *** + +# GIVEN + +a page to link to + Create content + ... type=Document + ... id=foo + ... title=Foo + +a page to edit + Create content + ... type=Document + ... id=bar + ... title=Bar + +# When +I add a link in rich text + Go To ${PLONE_URL}/bar/edit + Fill text to tinymce editor foo + Mark text foo in tinymce editor + Click //button[@aria-label="Insert/edit link"] + Click //div[contains(@class,"linkModal")]//div[contains(@class,"content-browser-selected-items-wrapper")]//a[contains(@class,"btn-primary")] + Click item in contenbrowser column 1 3 + Click //div[contains(@class, "content-browser-wrapper")]//div[contains(@class, "levelColumns")]/div[contains(@class, "preview")]/div[contains(@class, "levelToolbar")]//button + Click //div[contains(@class,"modal-footer")]//input[@name="insert"] + Click //button[@name="form.buttons.save"] + Wait For Condition Text //body contains Changes saved + +I remove link to page + Go To ${PLONE_URL}/bar + Click //*[@id="contentview-edit"]//a + Fill text to tinymce editor foo + Mark text foo in tinymce editor + Click //button[@aria-label="Remove link"] + Click //button[@name="form.buttons.save"] + Wait For Condition Text //body contains Changes saved + +# Then + +I should see a warning when deleting page + Go To ${PLONE_URL}/foo + Click //*[@id="plone-contentmenu-actions"]/a + Click //*[@id="plone-contentmenu-actions-delete"] + Get Element Count //*[contains(@class,"breach-container")]//*[contains(@class,"breach-item")] greater than 0 + +I should not see a warning when deleting page + Go To ${PLONE_URL}/foo + Click //*[@id="plone-contentmenu-actions"]/a + Click //*[@id="plone-contentmenu-actions-delete"] + Get Element Count //*[contains(@class,"breach-container")]//*[contains(@class,"breach-item")] should be 0 + + +I should see a warning when deleting page from folder_contents + Go To ${PLONE_URL}/folder_contents + Check Checkbox //tr[@data-id="foo"]//input + Get Checkbox State //tr[@data-id="foo"]//input == checked + Get Element Count //*[@id="btngroup-mainbuttons"]//a[@id="btn-delete" and contains(@class,"disabled")] should be 0 + Click //*[@id="btngroup-mainbuttons"]//a[@id="btn-delete"] + Get Element Count //*[contains(@class,"breach-container")]//*[contains(@class,"breach-item")] greater than 0 + Get Checkbox State //tr[@data-id="foo"]//input == checked + + +I should not see a warning when deleting page from folder_contents + Go To ${PLONE_URL}/folder_contents + Check Checkbox //tr[@data-id="foo"]//input + Get Checkbox State //tr[@data-id="foo"]//input == checked + Get Element Count //*[@id="btngroup-mainbuttons"]//a[@id="btn-delete" and contains(@class,"disabled")] should be 0 + Click //*[@id="btngroup-mainbuttons"]//a[@id="btn-delete"] + Get Element States //*[@id="popover-delete"]//*[contains(@class,"popover-content")] contains visible + Get Element Count //*[contains(@class,"breach-container")]//*[contains(@class,"breach-item")] should be 0 + Click //*[contains(@class,"popover-content")]//button[contains(@class,"applyBtn")] + Wait For Condition Text //body contains Successfully delete items + Get Element Count //tr[@data-id="foo"]//input should be 0 + +# DRY + +Mark text foo in tinymce editor + + # select the text `heading` via javascript + Evaluate JavaScript ${None} + ... () => { + ... let iframe_document = document.querySelector(".tox-edit-area iframe").contentDocument; + ... let body = iframe_document.body; + ... let p = body.firstChild; + ... let range = new Range(); + ... range.setStart(p.firstChild, 0); + ... range.setEnd(p.firstChild, 3); + ... iframe_document.getSelection().removeAllRanges(); + ... iframe_document.getSelection().addRange(range); + ... } + ... all_elements=False diff --git a/src/plone/app/layout/tests/test_confirm_view.py b/src/plone/app/layout/tests/test_confirm_view.py index 5628f1043..ebcaac9be 100644 --- a/src/plone/app/layout/tests/test_confirm_view.py +++ b/src/plone/app/layout/tests/test_confirm_view.py @@ -1,11 +1,11 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from zope.component import getMultiAdapter import unittest class TestAttackVector(unittest.TestCase): - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def test_using_correct_template(self): """Ensure that confirm-action view uses the confirm.pt template from plone.app.layout.""" diff --git a/src/plone/app/layout/tests/test_linkintegrity.py b/src/plone/app/layout/tests/test_linkintegrity.py new file mode 100644 index 000000000..bbff984d1 --- /dev/null +++ b/src/plone/app/layout/tests/test_linkintegrity.py @@ -0,0 +1,287 @@ +"""Functional tests for link integrity HTML rendering. + +These tests verify that the delete confirmation page correctly renders +link integrity warnings (HTML). The underlying API logic (get_breaches etc.) +is tested in plone.app.linkintegrity. +""" + +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING +from plone.app.linkintegrity.testing import create +from plone.app.linkintegrity.testing import GIF +from plone.app.linkintegrity.utils import getIncomingLinks +from plone.app.linkintegrity.utils import getOutgoingLinks +from plone.app.testing import login +from plone.app.testing import setRoles +from plone.app.testing import TEST_USER_ID +from plone.app.testing import TEST_USER_NAME +from plone.app.testing import TEST_USER_PASSWORD +from plone.app.textfield import RichTextValue +from plone.base.interfaces import IEditingSchema +from plone.registry.interfaces import IRegistry +from plone.testing.zope import Browser +from zc.relation.interfaces import ICatalog +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.lifecycleevent import modified + +import re +import transaction +import unittest + + +def set_text(obj, text): + obj.text = RichTextValue(text) + modified(obj) + + +class LinkIntegrityFunctionalTestCase(unittest.TestCase): + """Functional tests for link integrity HTML rendering in delete confirmation.""" + + layer = PLONE_APP_LAYOUT_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + login(self.portal, TEST_USER_NAME) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Create sample content + for i in range(1, 4): + create(self.portal, "Document", id=f"doc{i}", title=f"Test Page {i}") + create(self.portal, "File", id="file1", title="File 1", file=GIF) + create(self.portal, "Folder", id="folder1", title="Folder 1") + create(self.portal["folder1"], "Document", id="doc4", title="Test Page 4") + + # Get a testbrowser + self.browser = Browser(self.layer["app"]) + self.browser.handleErrors = False + self.browser.addHeader("Referer", self.portal.absolute_url()) + self.browser.addHeader( + "Authorization", f"Basic {TEST_USER_NAME}:{TEST_USER_PASSWORD}" + ) + + # Initial page load to compile bundles before rendering exception views + transaction.commit() + self.browser.open(self.portal.absolute_url()) + + def _get_token(self, obj): + return getMultiAdapter((obj, self.request), name="authenticator").token() + + def test_file_reference_linkintegrity_page_is_shown(self): + doc1 = self.portal.doc1 + file2 = create(self.portal, "File", id="file2", file=GIF) + + set_text(doc1, 'A File') + token = self._get_token(file2) + self.request["_authenticator"] = token + transaction.commit() + + self.browser.handleErrors = True + delete_url = "{}/delete_confirmation?_authenticator={}".format( + file2.absolute_url(), token + ) + self.browser.open(delete_url) + self.assertIn("Potential link breakage", self.browser.contents) + self.assertIn( + 'Test Page 1', self.browser.contents + ) + self.assertIn("Would you like to delete it anyway?", self.browser.contents) + + self.browser.getControl(name="form.buttons.Delete").click() + self.assertNotIn("file2", self.portal.objectIds()) + + def test_renaming_referenced_item(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + + set_text(doc1, 'doc2') + self.assertEqual([i.from_object for i in getIncomingLinks(doc2)], [doc1]) + transaction.commit() + + self.browser.handleErrors = True + self.browser.open( + "{}/object_rename?_authenticator={}".format( + doc1.absolute_url(), self._get_token(doc1) + ) + ) + self.browser.getControl(name="form.widgets.new_id").value = "nuname" + self.browser.getControl(name="form.buttons.Rename").click() + self.assertIn("Renamed 'doc1' to 'nuname'.", self.browser.contents) + transaction.commit() + + self.assertIn(doc1, [i.from_object for i in getIncomingLinks(doc2)]) + self.browser.open(doc2.absolute_url()) + self.browser.getLink("Delete").click() + self.assertIn("Potential link breakage", self.browser.contents) + self.assertIn( + 'Test Page 1', + self.browser.contents, + ) + self.browser.getControl(name="form.buttons.Delete").click() + self.assertNotIn("doc2", self.portal.objectIds()) + + def test_removal_in_subfolder(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + folder1 = self.portal.folder1 + + set_text(doc1, 'a document') + set_text(doc2, 'a document') + transaction.commit() + + self.browser.handleErrors = True + self.browser.open( + "{}/delete_confirmation?_authenticator={}".format( + folder1.absolute_url(), self._get_token(folder1) + ) + ) + self.assertIn("Potential link breakage", self.browser.contents) + self.assertIn( + 'Test Page 1', self.browser.contents + ) + self.assertIn( + 'Test Page 2', self.browser.contents + ) + self.browser.getControl(name="form.buttons.Delete").click() + self.assertNotIn("folder1", self.portal.objectIds()) + + def test_removal_with_cookie_auth(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + + set_text(doc1, 'doc2') + transaction.commit() + + browser = Browser(self.layer["app"]) + browser.handleErrors = True + browser.addHeader("Referer", self.portal.absolute_url()) + browser.open(f"{self.portal.absolute_url()}/folder_contents") + self.assertIn("login?came_from", browser.url) + + browser.getControl(name="__ac_name").value = TEST_USER_NAME + browser.getControl(name="__ac_password").value = TEST_USER_PASSWORD + browser.getControl("Log in").click() + self.assertNotIn("authorization", [h.lower() for h in browser.headers.keys()]) + + browser.open( + "{}/delete_confirmation?_authenticator={}".format( + doc2.absolute_url(), self._get_token(doc2) + ) + ) + self.assertIn("Potential link breakage", browser.contents) + self.assertIn( + 'Test Page 1', browser.contents + ) + browser.getControl(name="form.buttons.Delete").click() + self.assertNotIn("doc2", self.portal.objectIds()) + + def test_linkintegrity_on_off_switch(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + + set_text(doc1, 'a document') + transaction.commit() + + self.browser.handleErrors = True + self.browser.open( + "{}/delete_confirmation?_authenticator={}".format( + doc2.absolute_url(), self._get_token(doc2) + ) + ) + self.assertIn("Potential link breakage", self.browser.contents) + + registry = getUtility(IRegistry) + settings = registry.forInterface(IEditingSchema, prefix="plone") + settings.enable_link_integrity_checks = False + transaction.commit() + self.browser.reload() + self.assertNotIn("Potential link breakage", self.browser.contents) + + def test_references_on_cloned_objects(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + + set_text(doc1, 'a document') + token = self._get_token(doc1) + self.request["_authenticator"] = token + doc1.restrictedTraverse("object_copy")() + self.request["_authenticator"] = token + self.portal.restrictedTraverse("object_paste")() + self.assertIn("copy_of_doc1", self.portal) + transaction.commit() + + self.browser.handleErrors = True + self.browser.open( + "{}/delete_confirmation?_authenticator={}".format( + doc2.absolute_url(), self._get_token(doc2) + ) + ) + self.assertIn("Potential link breakage", self.browser.contents) + self.assertIn( + 'Test Page 1', self.browser.contents + ) + self.assertIn( + 'a document') + self.assertEqual([i.to_object for i in getOutgoingLinks(doc1)], [spaces1]) + transaction.commit() + + self.browser.handleErrors = True + self.browser.open( + "{}/delete_confirmation?_authenticator={}".format( + spaces1.absolute_url(), self._get_token(spaces1) + ) + ) + self.assertIn("Potential link breakage", self.browser.contents) + self.assertIn( + 'Test Page 1', self.browser.contents + ) + self.browser.getControl(name="form.buttons.Delete").click() + self.assertNotIn("some spaces.doc", self.portal.objectIds()) + + def test_warn_about_content(self): + folder1 = self.portal.folder1 + self.browser.open( + "{}/delete_confirmation?_authenticator={}".format( + folder1.absolute_url(), self._get_token(folder1) + ) + ) + self.assertIn("Number of selected", self.browser.contents) + self.assertTrue(re.search(r"2\s+Objects in all", self.browser.contents)) + self.assertTrue(re.search(r"1\s+Folders", self.browser.contents)) + self.assertTrue(re.search(r"0\s+Published objects", self.browser.contents)) + + def test_update(self): + doc1 = self.portal.doc1 + doc2 = self.portal.doc2 + doc4 = self.portal.folder1.doc4 + + set_text(doc1, 'a document') + set_text(doc2, 'a document') + + catalog = getUtility(ICatalog) + for rel in list(catalog.findRelations()): + catalog.unindex(rel) + + self.assertEqual([i.to_object for i in getOutgoingLinks(doc1)], []) + self.assertEqual([i.to_object for i in getOutgoingLinks(doc2)], []) + + transaction.commit() + self.browser.open( + f"{self.portal.absolute_url()}/updateLinkIntegrityInformation" + ) + self.browser.getControl("Update").click() + self.assertIn("Link integrity information updated for", self.browser.contents) + + self.assertEqual([i.to_object for i in getOutgoingLinks(doc1)], [doc2]) + self.assertEqual([i.to_object for i in getOutgoingLinks(doc2)], [doc4]) diff --git a/src/plone/app/layout/tests/test_robot.py b/src/plone/app/layout/tests/test_robot.py new file mode 100644 index 000000000..8f8a1bbc3 --- /dev/null +++ b/src/plone/app/layout/tests/test_robot.py @@ -0,0 +1,30 @@ +from plone.app.layout.testing import PLONE_APP_LAYOUT_ROBOT_TESTING +from plone.app.testing import ROBOT_TEST_LEVEL +from plone.testing import layered + +import os +import robotsuite +import unittest + + +def test_suite(): + suite = unittest.TestSuite() + current_dir = os.path.abspath(os.path.dirname(__file__)) + robot_dir = os.path.join(current_dir, "robot") + robot_tests = [ + os.path.join("robot", doc) + for doc in os.listdir(robot_dir) + if doc.endswith(".robot") and doc.startswith("test_") + ] + for robot_test in robot_tests: + robottestsuite = robotsuite.RobotTestSuite( + robot_test, + noncritical=["unstable"], + ) + robottestsuite.level = ROBOT_TEST_LEVEL + suite.addTests( + [ + layered(robottestsuite, layer=PLONE_APP_LAYOUT_ROBOT_TESTING), + ] + ) + return suite diff --git a/src/plone/app/layout/tests/test_setup.py b/src/plone/app/layout/tests/test_setup.py index 36107b4d4..f91b08a32 100644 --- a/src/plone/app/layout/tests/test_setup.py +++ b/src/plone/app/layout/tests/test_setup.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.base.utils import get_installer import unittest @@ -7,7 +7,7 @@ class TestSetup(unittest.TestCase): """Test plone.app.layout setup.""" - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] @@ -21,7 +21,7 @@ def test_browserlayer(self): class TestUninstall(unittest.TestCase): - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/viewlets/tests/base.py b/src/plone/app/layout/viewlets/tests/base.py index 53c4c7d91..8a0400272 100644 --- a/src/plone/app/layout/viewlets/tests/base.py +++ b/src/plone/app/layout/viewlets/tests/base.py @@ -1,12 +1,12 @@ -from plone.app.layout.testing import FUNCTIONAL_TESTING -from plone.app.layout.testing import INTEGRATION_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING from plone.app.layout.testing import TEST_USER_ID import unittest class ViewletsTestCase(unittest.TestCase): - layer = INTEGRATION_TESTING + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING def setUp(self): self.portal = self.layer["portal"] @@ -15,7 +15,7 @@ def setUp(self): class ViewletsFunctionalTestCase(unittest.TestCase): - layer = FUNCTIONAL_TESTING + layer = PLONE_APP_LAYOUT_FUNCTIONAL_TESTING def setUp(self): self.portal = self.layer["portal"] diff --git a/src/plone/app/layout/viewlets/tests/test_functional.py b/src/plone/app/layout/viewlets/tests/test_functional.py index c6924c6d9..a3343567a 100644 --- a/src/plone/app/layout/viewlets/tests/test_functional.py +++ b/src/plone/app/layout/viewlets/tests/test_functional.py @@ -1,4 +1,4 @@ -from plone.app.layout.testing import FUNCTIONAL_TESTING +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING from plone.testing import layered import doctest @@ -19,7 +19,7 @@ def test_suite(): test, optionflags=optionflags, ), - layer=FUNCTIONAL_TESTING, + layer=PLONE_APP_LAYOUT_FUNCTIONAL_TESTING, ) for test in normal_testfiles ] diff --git a/tox.ini b/tox.ini index 08f955177..f83d447cc 100644 --- a/tox.ini +++ b/tox.ini @@ -84,8 +84,10 @@ commands = [test_runner] deps = zope.testrunner test = + rfbrowser init zope-testrunner --all --test-path={toxinidir}/src -s plone.app.layout {posargs} coverage = + rfbrowser init coverage run --branch --source plone.app.layout {envbindir}/zope-testrunner --quiet --all --test-path={toxinidir}/src -s plone.app.layout {posargs} coverage report -m --format markdown coverage xml