diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73447527bc..72d7ada3ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,7 +3,7 @@ on: [push, pull_request] jobs: build: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: diff --git a/CHANGES.rst b/CHANGES.rst index 252e57258f..ea84b6bbac 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,58 @@ Changelog .. towncrier release notes start +9.9.0 (2024-12-18) +------------------ + +New features: + + +- When a Link content item is linked by UID, resolve its URL as the linked target URL for anonymous users. @cekk (#1847) + + +Bug fixes: + + +- Fix resolving paths in deserializer if the target was moved in the same request. @cekk (#1848) +- Make slate block linkintegrity checking more robust in case data isn't in the expected format. @cekk (#1849) +- Optimized performance of DexterityObjectPrimaryFieldTarget adapter. @maurits (#1851) + + +Internal: + + +- Fix time-dependence of tests. @davisagli (#1850) + + +9.8.5 (2024-11-25) +------------------ + +Bug fixes: + + +- Fix log in after changing email when "email as login" is enabled + [erral] (#1835) +- Fix tests after #1839 and plone.app.event#411 + [erral] (#1844) +- Do not change request during relation fields serialization + [cekk] (#1845) + + +Internal: + + +- Test that recurrence serialization provides correct data + [erral] (#1809) +- Additional tests to login name changes + [erral] (#1840) + + +Documentation: + + +- `html_use_opensearch` value must not have a trailing slash. Clean up comments. @stevepiercy (#1846) + + 9.8.4 (2024-11-05) ------------------ diff --git a/docs/source/conf.py b/docs/source/conf.py index 5bc2f084a2..4282d26b9f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -290,7 +290,7 @@ def patch_pygments_to_highlight_jsonschema(): # base URL from which the finished HTML is served. # Announce that we have an opensearch plugin # https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_use_opensearch -html_use_opensearch = "https://plonerestapi.readthedocs.org/" +html_use_opensearch = "https://plonerestapi.readthedocs.org" # This is the file name suffix for HTML files (e.g. ".xhtml"). diff --git a/news/1835.bugfix b/news/1835.bugfix deleted file mode 100644 index e8b78b308a..0000000000 --- a/news/1835.bugfix +++ /dev/null @@ -1,2 +0,0 @@ -Fix log in after changing email when "email as login" is enabled -[erral] diff --git a/setup.py b/setup.py index 69b2ff7184..15942cd754 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import sys -version = "9.8.5.dev0" +version = "9.9.1.dev0" if sys.version_info.major == 2: raise ValueError( diff --git a/src/plone/restapi/blocks_linkintegrity.py b/src/plone/restapi/blocks_linkintegrity.py index 754f5c256b..dc5343ff1d 100644 --- a/src/plone/restapi/blocks_linkintegrity.py +++ b/src/plone/restapi/blocks_linkintegrity.py @@ -69,9 +69,8 @@ def __init__(self, context, request): def __call__(self, block): value = (block or {}).get(self.field, []) children = iterate_children(value or []) - for child in children: - node_type = child.get("type") + node_type = child.get("type", "") if node_type: handler = getattr(self, f"handle_{node_type}", None) if handler: diff --git a/src/plone/restapi/deserializer/blocks.py b/src/plone/restapi/deserializer/blocks.py index 59e26a807c..e4a8f825be 100644 --- a/src/plone/restapi/deserializer/blocks.py +++ b/src/plone/restapi/deserializer/blocks.py @@ -24,9 +24,10 @@ def iterate_children(value): queue = deque(value) while queue: child = queue.pop() - yield child - if child.get("children"): - queue.extend(child["children"] or []) + if isinstance(child, dict): + yield child + if child.get("children", []): + queue.extend(child["children"] or []) @implementer(IFieldDeserializer) diff --git a/src/plone/restapi/deserializer/utils.py b/src/plone/restapi/deserializer/utils.py index 67d67d5556..c6b687fa2f 100644 --- a/src/plone/restapi/deserializer/utils.py +++ b/src/plone/restapi/deserializer/utils.py @@ -2,6 +2,9 @@ from plone.uuid.interfaces import IUUID from plone.uuid.interfaces import IUUIDAware from zope.component import getMultiAdapter +from plone.app.redirector.interfaces import IRedirectionStorage +from zope.component import getUtility + import re PATH_RE = re.compile(r"^(.*?)((?=/@@|#).*)?$") @@ -35,6 +38,14 @@ def path2uid(context, link): suffix = match.group(2) or "" obj = portal.unrestrictedTraverse(path, None) + if obj is None: + # last try: maybe the object or some parent has been renamed. + # if yes, there should be a reference into redirection storage + storage = getUtility(IRedirectionStorage) + alias_path = storage.get(path) + if alias_path: + path = alias_path + obj = portal.unrestrictedTraverse(path, None) if obj is None or obj == portal: return link segments = path.split("/") diff --git a/src/plone/restapi/serializer/configure.zcml b/src/plone/restapi/serializer/configure.zcml index 0e84f64f42..32c63b2d78 100644 --- a/src/plone/restapi/serializer/configure.zcml +++ b/src/plone/restapi/serializer/configure.zcml @@ -8,6 +8,8 @@ + + diff --git a/src/plone/restapi/serializer/dxcontent.py b/src/plone/restapi/serializer/dxcontent.py index 1c546d091d..eb0fc44c6e 100644 --- a/src/plone/restapi/serializer/dxcontent.py +++ b/src/plone/restapi/serializer/dxcontent.py @@ -1,6 +1,7 @@ from AccessControl import getSecurityManager from Acquisition import aq_inner from Acquisition import aq_parent +from plone.app.contenttypes.interfaces import ILink from plone.autoform.interfaces import READ_PERMISSIONS_KEY from plone.dexterity.interfaces import IDexterityContainer from plone.dexterity.interfaces import IDexterityContent @@ -219,23 +220,26 @@ def __init__(self, context, request): def __call__(self): primary_field_name = self.get_primary_field_name() + if not primary_field_name: + return for schema in iterSchemata(self.context): read_permissions = mergedTaggedValueDict(schema, READ_PERMISSIONS_KEY) - for name, field in getFields(schema).items(): - if not self.check_permission(read_permissions.get(name), self.context): - continue - - if name != primary_field_name: - continue - - target_adapter = queryMultiAdapter( - (field, self.context, self.request), IPrimaryFieldTarget - ) - if target_adapter: - target = target_adapter() - if target: - return target + field = getFields(schema).get(primary_field_name) + if field is None: + continue + if not self.check_permission( + read_permissions.get(primary_field_name), + self.context, + ): + return + + target_adapter = queryMultiAdapter( + (field, self.context, self.request), IPrimaryFieldTarget + ) + if not target_adapter: + return + return target_adapter() def get_primary_field_name(self): fieldname = None @@ -266,3 +270,27 @@ def check_permission(self, permission_name, obj): sm.checkPermission(permission.title, obj) ) return self.permission_cache[permission_name] + + +@adapter(ILink, Interface) +@implementer(IObjectPrimaryFieldTarget) +class LinkObjectPrimaryFieldTarget: + def __init__(self, context, request): + self.context = context + self.request = request + + self.permission_cache = {} + + def __call__(self): + """ + If user can edit Link object, do not return remoteUrl + """ + pm = getToolByName(self.context, "portal_membership") + if bool(pm.isAnonymousUser()): + for schema in iterSchemata(self.context): + for name, field in getFields(schema).items(): + if name == "remoteUrl": + serializer = queryMultiAdapter( + (field, self.context, self.request), IFieldSerializer + ) + return serializer() diff --git a/src/plone/restapi/serializer/relationfield.py b/src/plone/restapi/serializer/relationfield.py index d54cadb9d1..3115568d0e 100644 --- a/src/plone/restapi/serializer/relationfield.py +++ b/src/plone/restapi/serializer/relationfield.py @@ -18,7 +18,7 @@ @implementer(IJsonCompatible) def relationvalue_converter(value): if value.to_object: - request = getRequest() + request = getRequest().clone() request.form["metadata_fields"] = ["UID"] summary = getMultiAdapter((value.to_object, request), ISerializeToJsonSummary)() return json_compatible(summary) diff --git a/src/plone/restapi/services/users/update.py b/src/plone/restapi/services/users/update.py index 3d7d3b724b..e2fdb19604 100644 --- a/src/plone/restapi/services/users/update.py +++ b/src/plone/restapi/services/users/update.py @@ -107,7 +107,20 @@ def reply(self): if security.use_email_as_login and "email" in user_settings_to_update: value = user_settings_to_update["email"] pas = getToolByName(self.context, "acl_users") - pas.updateLoginName(user.getId(), value) + + try: + pas.updateLoginName(user.getId(), value) + except ValueError: + return self._error( + 400, + "Bad request", + _( + "Cannot update login name of user to '${new_email}'.", + mapping={ + "new_email": value, + }, + ), + ) roles = user_settings_to_update.get("roles", {}) if roles: @@ -149,7 +162,17 @@ def reply(self): if security.use_email_as_login and "email" in user_settings_to_update: value = user_settings_to_update["email"] - set_own_login_name(user, value) + try: + set_own_login_name(user, value) + except ValueError: + return self._error( + 400, + "Bad request", + _( + "Cannot update login name of user to '${new_email}'.", + mapping={"new_email": value}, + ), + ) else: if self._is_anonymous: diff --git a/src/plone/restapi/tests/test_blocks_deserializer.py b/src/plone/restapi/tests/test_blocks_deserializer.py index 874e5446e8..dcaf3bf2c8 100644 --- a/src/plone/restapi/tests/test_blocks_deserializer.py +++ b/src/plone/restapi/tests/test_blocks_deserializer.py @@ -1,3 +1,4 @@ +from plone import api from plone.dexterity.interfaces import IDexterityFTI from plone.dexterity.interfaces import IDexterityItem from plone.restapi.behaviors import IBlocks @@ -724,3 +725,16 @@ def test_deserialize_url_with_image_scales(self): res = self.deserialize(blocks=blocks) self.assertTrue(res.blocks["123"]["url"].startswith("../resolveuid/")) self.assertNotIn("image_scales", res.blocks["123"]) + + def test_deserializer_resolve_path_also_if_it_is_an_alias(self): + + self.portal.invokeFactory( + "Document", + id="doc", + ) + api.content.move(source=self.portal.doc, id="renamed-doc") + blocks = {"abc": {"href": "%s/doc" % self.portal.absolute_url()}} + + res = self.deserialize(blocks=blocks) + link = res.blocks["abc"]["href"] + self.assertEqual(link, f"../resolveuid/{self.portal['renamed-doc'].UID()}") diff --git a/src/plone/restapi/tests/test_dxcontent_serializer.py b/src/plone/restapi/tests/test_dxcontent_serializer.py index 567aa01e62..1989181472 100644 --- a/src/plone/restapi/tests/test_dxcontent_serializer.py +++ b/src/plone/restapi/tests/test_dxcontent_serializer.py @@ -16,11 +16,13 @@ from plone.namedfile.file import NamedFile from plone.registry.interfaces import IRegistry from plone.restapi.interfaces import IExpandableElement +from plone.restapi.interfaces import IObjectPrimaryFieldTarget from plone.restapi.interfaces import ISerializeToJson from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING from plone.restapi.tests.test_expansion import ExpandableElementFoo from plone.restapi.serializer.utils import get_portal_type_title from plone.uuid.interfaces import IMutableUUID +from plone.uuid.interfaces import IUUID from Products.CMFCore.utils import getToolByName from zope.component import getGlobalSiteManager from zope.component import getMultiAdapter @@ -756,3 +758,38 @@ def test_primary_field_target_with_edit_permissions(self): serializer = getMultiAdapter((self.portal.doc1, self.request), ISerializeToJson) data = serializer() self.assertNotIn("targetUrl", data) + + def test_primary_field_target_for_link_objects_for_auth_return_none(self): + self.portal.invokeFactory( + "Document", + id="linked", + ) + self.portal.invokeFactory( + "Link", + id="link", + remoteUrl=f"../resolveuid/{IUUID(self.portal.linked)}", + ) + wftool = getToolByName(self.portal, "portal_workflow") + wftool.doActionFor(self.portal.linked, "publish") + adapter = getMultiAdapter( + (self.portal.link, self.request), IObjectPrimaryFieldTarget + ) + self.assertEqual(adapter(), None) + + def test_primary_field_target_for_link_objects_for_anonymous(self): + self.portal.invokeFactory( + "Document", + id="linked", + ) + self.portal.invokeFactory( + "Link", + id="link", + remoteUrl=f"../resolveuid/{IUUID(self.portal.linked)}", + ) + wftool = getToolByName(self.portal, "portal_workflow") + wftool.doActionFor(self.portal.linked, "publish") + logout() + adapter = getMultiAdapter( + (self.portal.link, self.request), IObjectPrimaryFieldTarget + ) + self.assertEqual(adapter(), self.portal.linked.absolute_url()) diff --git a/src/plone/restapi/tests/test_dxfield_serializer.py b/src/plone/restapi/tests/test_dxfield_serializer.py index bac16ffcc3..625ea83d57 100644 --- a/src/plone/restapi/tests/test_dxfield_serializer.py +++ b/src/plone/restapi/tests/test_dxfield_serializer.py @@ -325,6 +325,19 @@ def test_relationlist_field_serialization_returns_list(self): value, ) + def test_relation_field_serialization_do_not_change_request(self): + self.request.form["metadata_fields"] = ["foo", "bar"] + doc2 = self.portal[ + self.portal.invokeFactory( + "DXTestDocument", + id="doc2", + title="Referenceable Document", + description="Description 2", + ) + ] + self.serialize("test_relationchoice_field", doc2) + self.assertEqual(self.request.form["metadata_fields"], ["foo", "bar"]) + def test_remoteurl_field_in_links_get_converted(self): link = self.portal[ self.portal.invokeFactory( diff --git a/src/plone/restapi/tests/test_serializer_summary.py b/src/plone/restapi/tests/test_serializer_summary.py index 16b54f7b0c..e186c2f31c 100644 --- a/src/plone/restapi/tests/test_serializer_summary.py +++ b/src/plone/restapi/tests/test_serializer_summary.py @@ -1,19 +1,32 @@ +from datetime import datetime +from datetime import timedelta from DateTime import DateTime from plone.app.contentlisting.interfaces import IContentListingObject +from plone.app.event.dx.traverser import OccurrenceTraverser from plone.app.testing import popGlobalRegistry from plone.app.testing import pushGlobalRegistry from plone.dexterity.utils import createContentInContainer +from plone.event.interfaces import IEvent +from plone.event.interfaces import IEventRecurrence from plone.restapi.interfaces import ISerializeToJsonSummary from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING from plone.restapi.testing import register_static_uuid_utility from Products.CMFCore.utils import getToolByName from zope.component import getMultiAdapter from zope.component.hooks import getSite +from zope.interface import alsoProvides import Missing +import pytz import unittest +try: + from plone.app.event.adapters import OccurrenceContentListingObject +except ImportError: + OccurrenceContentListingObject = None + + class TestSummarySerializers(unittest.TestCase): layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING @@ -203,3 +216,69 @@ def test_dx_type_summary(self): }, summary, ) + + +class TestSummarySerializerswithRecurrenceObjects(unittest.TestCase): + layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + pushGlobalRegistry(getSite()) + register_static_uuid_utility(prefix="c6dcbd55ab2746e199cd4ed458") + + behaviors = self.portal.portal_types.DXTestDocument.behaviors + behaviors = behaviors + ( + "plone.eventbasic", + "plone.eventrecurrence", + ) + self.portal.portal_types.DXTestDocument.behaviors = behaviors + + self.start = datetime(1995, 7, 31, 13, 45, tzinfo=pytz.timezone("UTC")) + self.event = createContentInContainer( + self.portal, + "DXTestDocument", + id="doc1", + title="Lorem Ipsum event", + description="Description event", + start=self.start, + end=self.start + timedelta(hours=1), + recurrence="RRULE:FREQ=DAILY;COUNT=3", # see https://github.com/plone/plone.app.event/blob/master/plone/app/event/tests/base_setup.py + ) + + alsoProvides(self.event, IEvent) + alsoProvides(self.event, IEventRecurrence) + + def tearDown(self): + popGlobalRegistry(getSite()) + + @unittest.skipIf( + OccurrenceContentListingObject is not None, + "this test needs a plone.app.event version that does not include a IContentListingObject adapter", + ) + def test_dx_event_with_recurrence_old_version(self): + tomorrow = self.start + timedelta(days=1) + tomorrow_str = tomorrow.strftime("%Y-%m-%d") + ot = OccurrenceTraverser(self.event, self.request) + ocurrence = ot.publishTraverse(self.request, tomorrow_str) + + with self.assertRaises(TypeError): + getMultiAdapter((ocurrence, self.request), ISerializeToJsonSummary)() + + @unittest.skipIf( + OccurrenceContentListingObject is None, + "this test needs a plone.app.event version that includes a IContentListingObject adapter", + ) + def test_dx_event_with_recurrence_new_version(self): + tomorrow = self.start + timedelta(days=1) + tomorrow_str = tomorrow.strftime("%Y-%m-%d") + ot = OccurrenceTraverser(self.event, self.request) + ocurrence = ot.publishTraverse(self.request, tomorrow_str) + self.request.form["metadata_fields"] = ["start"] + summary = getMultiAdapter((ocurrence, self.request), ISerializeToJsonSummary)() + self.assertEqual( + datetime.fromisoformat(summary["start"]).date().isoformat(), + tomorrow.date().isoformat(), + ) + self.assertEqual(summary["title"], ocurrence.Title()) diff --git a/src/plone/restapi/tests/test_services_users.py b/src/plone/restapi/tests/test_services_users.py index 3d80320769..2352f62aff 100644 --- a/src/plone/restapi/tests/test_services_users.py +++ b/src/plone/restapi/tests/test_services_users.py @@ -1663,3 +1663,139 @@ def test_user_changes_email_when_login_with_email_and_uuid_userids(self): }, ) self.assertTrue(new_login_with_new_email_response.ok) + + def test_manager_changes_email_to_existing_when_login_with_email(self): + """test that when login with email is enabled and a manager tries to change a user's email + to a previously existing one + """ + # enable use_email_as_login + security_settings = getAdapter(self.portal, ISecuritySchema) + security_settings.use_email_as_login = True + transaction.commit() + + # Create user 1 + response = self.api_session.post( + "/@users", + json={ + "email": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(response.ok) + userid = response.json()["id"] + + # Create user 2 + response = self.api_session.post( + "/@users", + json={ + "email": "second@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(response.ok) + + transaction.commit() + + # Log in + anon_response = self.anon_api_session.post( + "/@login", + json={ + "login": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(anon_response.ok) + + # try to change the email to an existing one, it should fail + email_change_response = self.api_session.patch( + f"/@users/{userid}", + json={ + "email": "second@example.com", + }, + ) + self.assertFalse(email_change_response.ok) + self.assertEqual(email_change_response.status_code, 400) + email_change_response_json = email_change_response.json() + self.assertEqual( + email_change_response_json.get("error", {}).get("message"), + "Cannot update login name of user to 'second@example.com'.", + ) + + # Email was not changed, so log in with the old one + new_login_with_old_email_response = self.anon_api_session.post( + "/@login", + json={ + "login": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(new_login_with_old_email_response.ok) + + def test_user_changes_email_to_existing_one_when_login_with_email(self): + """test that when login with email is enabled and the user changes their email + they can log in with the new email + """ + # enable use_email_as_login + security_settings = getAdapter(self.portal, ISecuritySchema) + security_settings.use_email_as_login = True + transaction.commit() + + # Create user 1 + response = self.api_session.post( + "/@users", + json={ + "email": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(response.ok) + userid = response.json()["id"] + + # Create user 2 + response = self.api_session.post( + "/@users", + json={ + "email": "second@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(response.ok) + transaction.commit() + + # log in with email + anon_response = self.anon_api_session.post( + "/@login", + json={ + "login": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(anon_response.ok) + auth_token = anon_response.json().get("token") + + user_api_session = RelativeSession(self.portal_url, test=self) + user_api_session.headers.update({"Accept": "application/json"}) + user_api_session.headers.update({"Authorization": f"Bearer {auth_token}"}) + + # try to change e-mail to an existing one, it should fail + email_change_response = user_api_session.patch( + f"/@users/{userid}", + json={"email": "second@example.com"}, + ) + + self.assertEqual(email_change_response.status_code, 400) + email_change_response_json = email_change_response.json() + self.assertEqual( + email_change_response_json.get("error", {}).get("message"), + "Cannot update login name of user to 'second@example.com'.", + ) + + # email was not changed, so log in with the old one + new_login_with_old_email_response = self.anon_api_session.post( + "/@login", + json={ + "login": "howard.zinn@example.com", + "password": TEST_USER_PASSWORD, + }, + ) + self.assertTrue(new_login_with_old_email_response.ok)