diff --git a/plone/app/discussion/conversation.py b/plone/app/discussion/conversation.py index ba27c0f..ca22c05 100644 --- a/plone/app/discussion/conversation.py +++ b/plone/app/discussion/conversation.py @@ -50,8 +50,14 @@ from plone.app.discussion.comment import Comment from AccessControl.SpecialUsers import nobody as user_nobody +from ComputedAttribute import ComputedAttribute + ANNOTATION_KEY = 'plone.app.discussion:conversation' +def computed_attribute_decorator(level=0): + def computed_attribute_wrapper(func): + return ComputedAttribute(func, level) + return computed_attribute_wrapper class Conversation(Traversable, Persistent, Explicit): """A conversation is a container for all comments on a content object. @@ -87,21 +93,21 @@ class Conversation(Traversable, Persistent, Explicit): parent = aq_inner(self.__parent__) return parent.restrictedTraverse('@@conversation_view').enabled() - @property + @computed_attribute_decorator(level=1) def total_comments(self): public_comments = [ - x for x in self._comments.values() + x for x in self.values() if user_nobody.has_permission('View', x) ] return len(public_comments) - @property + @computed_attribute_decorator(level=1) def last_comment_date(self): # self._comments is an Instance of a btree. The keys # are always ordered comment_keys = self._comments.keys() for comment_key in reversed(comment_keys): - comment = self._comments[comment_key] + comment = self[comment_key] if user_nobody.has_permission('View', comment): return comment.creation_date return None @@ -110,10 +116,10 @@ class Conversation(Traversable, Persistent, Explicit): def commentators(self): return self._commentators - @property + @computed_attribute_decorator(level=1) def public_commentators(self): retval = set() - for comment in self._comments.values(): + for comment in self.values(): if not user_nobody.has_permission('View', comment): continue retval.add(comment.author_username) diff --git a/plone/app/discussion/testing.py b/plone/app/discussion/testing.py index e7a397d..e47026f 100644 --- a/plone/app/discussion/testing.py +++ b/plone/app/discussion/testing.py @@ -41,10 +41,14 @@ class PloneAppDiscussion(PloneSandboxLayer): xmlconfig.file('configure.zcml', plone.app.discussion, context=configurationContext) + xmlconfig.file('configure.zcml', + plone.app.discussion.tests, + context=configurationContext) def setUpPloneSite(self, portal): # Install into Plone site using portal_setup applyProfile(portal, 'plone.app.discussion:default') + applyProfile(portal, 'plone.app.discussion.tests:testing') # Creates some users acl_users = getToolByName(portal, 'acl_users') diff --git a/plone/app/discussion/tests/configure.zcml b/plone/app/discussion/tests/configure.zcml new file mode 100644 index 0000000..ee79b95 --- /dev/null +++ b/plone/app/discussion/tests/configure.zcml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/plone/app/discussion/tests/profile/types.xml b/plone/app/discussion/tests/profile/types.xml new file mode 100644 index 0000000..0aec569 --- /dev/null +++ b/plone/app/discussion/tests/profile/types.xml @@ -0,0 +1,4 @@ + + + + diff --git a/plone/app/discussion/tests/profile/types/sample_content_type.xml b/plone/app/discussion/tests/profile/types/sample_content_type.xml new file mode 100644 index 0000000..7869638 --- /dev/null +++ b/plone/app/discussion/tests/profile/types/sample_content_type.xml @@ -0,0 +1,47 @@ + + + + + sample_content_type + Sample Content + document_icon.png + True + True + + + True + + plone.dexterity.content.Item + + cmf.AddPortalContent + + + + + + view + False + + + + + + + + + + + + + + + + + + + diff --git a/plone/app/discussion/tests/profile/workflows.xml b/plone/app/discussion/tests/profile/workflows.xml new file mode 100644 index 0000000..500f444 --- /dev/null +++ b/plone/app/discussion/tests/profile/workflows.xml @@ -0,0 +1,4 @@ + + + + diff --git a/plone/app/discussion/tests/profile/workflows/comment_workflow_acquired_view/definition.xml b/plone/app/discussion/tests/profile/workflows/comment_workflow_acquired_view/definition.xml new file mode 100644 index 0000000..89a9fb2 --- /dev/null +++ b/plone/app/discussion/tests/profile/workflows/comment_workflow_acquired_view/definition.xml @@ -0,0 +1,75 @@ + + + Access contents information + Change portal events + Modify portal content + View + + Visible to everyone, editable by the owner. + + Anonymous + + + Editor + Manager + Owner + Site Administrator + + + Editor + Manager + Owner + Site Administrator + + + + + + Previous transition + + transition/getId|nothing + + + + + + The ID of the user who performed the previous transition + + user/getId + + + + + + Comment about the last transition + + python:state_change.kwargs.get('comment', '') + + + + + + Provides access to workflow history + + state_change/getHistory + + + Request review + Review portal content + + + + When the previous transition was performed + + state_change/getDateTime + + + + + diff --git a/plone/app/discussion/tests/test_acquisition.py b/plone/app/discussion/tests/test_acquisition.py new file mode 100644 index 0000000..c6a96db --- /dev/null +++ b/plone/app/discussion/tests/test_acquisition.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +from AccessControl.User import User # before SpecialUsers +from AccessControl.SpecialUsers import nobody as user_nobody +from AccessControl.PermissionRole import rolesForPermissionOn +from Acquisition import aq_chain, aq_base +from plone.app.discussion.testing import \ + PLONE_APP_DISCUSSION_INTEGRATION_TESTING +from plone.app.discussion.interfaces import IConversation +from plone.app.testing import TEST_USER_ID, setRoles +from Products.CMFCore.utils import getToolByName +from zope.component import createObject + +import unittest2 as unittest + + +dexterity_type_name = 'sample_content_type' +dexterity_object_id = 'instance-of-dexterity-type' +archetypes_object_id = 'instance-of-archetypes-type' +one_state_workflow = 'one_state_workflow' +comment_workflow_acquired_view = 'comment_workflow_acquired_view' + +class AcquisitionTest(unittest.TestCase): + """ Define the expected behaviour of wrapped and unwrapped comments. """ + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + setRoles(self.portal, TEST_USER_ID, ['Manager']) + self.wftool = getToolByName(self.portal, 'portal_workflow') + + # Use customized workflow for comments. + self.wftool.setChainForPortalTypes( + ['Discussion Item'], + (comment_workflow_acquired_view,), + ) + + # Use one_state_workflow for Document and sample_content_type, + # so they're always published. + self.wftool.setChainForPortalTypes( + ['Document', dexterity_type_name], + (one_state_workflow,), + ) + + # Create a dexterity item and add a comment. + self.portal.invokeFactory( + id=dexterity_object_id, + title='Instance Of Dexterity Type', + type_name=dexterity_type_name, + ) + + self.dexterity_object = self.portal.get(dexterity_object_id) + dx_conversation = IConversation(self.dexterity_object) + self.dexterity_conversation = dx_conversation + dx_comment = createObject('plone.Comment') + dx_conversation.addComment(dx_comment) + self.unwrapped_dexterity_comment = dx_comment + self.wrapped_dexterity_comment = dx_conversation[dx_comment.id] + + # Create an Archetypes item and add a comment. + self.portal.invokeFactory( + id=archetypes_object_id, + title='Instance Of Archetypes Type', + type_name='Document', + ) + + self.archetypes_object = self.portal.get(archetypes_object_id) + at_conversation = IConversation(self.archetypes_object) + self.archetypes_conversation = at_conversation + at_comment = createObject('plone.Comment') + at_conversation.addComment(at_comment) + self.unwrapped_archetypes_comment = at_comment + self.wrapped_archetypes_comment = at_conversation[at_comment.id] + + + def test_workflows_installed(self): + """Check that the new comment workflow has been installed properly. + (Just a test to check our test setup.) + """ + workflows = self.wftool.objectIds() + self.assertTrue('comment_workflow_acquired_view' in workflows) + + def test_workflows_applied(self): + """Check that all objects have the workflow that we expect. + (Just a test to check our test setup.)""" + self.assertEqual( + self.wftool.getChainFor(self.archetypes_object), + (one_state_workflow,) + ) + self.assertEqual( + self.wftool.getChainFor(self.dexterity_object), + (one_state_workflow,) + ) + self.assertEqual( + self.wftool.getChainFor(self.unwrapped_archetypes_comment), + (comment_workflow_acquired_view,) + ) + self.assertEqual( + self.wftool.getChainFor(self.unwrapped_dexterity_comment), + (comment_workflow_acquired_view,) + ) + + def test_comment_acquisition_chain(self): + """ Test that the acquisition chains for wrapped and unwrapped + comments are as expected. """ + + # Unwrapped comments rely on __parent__ attributes to determine + # parentage. Frustratingly there is no guarantee that __parent__ + # is always set, so the computed acquisition chain may be short. + # In this case the unwrapped AT and DX objects stored as the + # conversation parents don't have a __parent__, preventing the portal + # from being included in the chain. + self.assertNotIn(self.portal, + aq_chain(self.unwrapped_archetypes_comment)) + self.assertNotIn(self.portal, + aq_chain(self.unwrapped_dexterity_comment)) + + # Wrapped comments however have a complete chain and thus can find the + # portal object reliably. + self.assertIn(self.portal,aq_chain(self.wrapped_archetypes_comment)) + self.assertIn(self.portal,aq_chain(self.wrapped_dexterity_comment)) + + + def test_acquiring_comment_permissions(self): + """ Unwrapped comments should not be able to acquire permissions + controlled by unreachable objects """ + + # We use the "Allow sendto" permission as by default it is + # controlled by the portal, which is unreachable via __parent__ + # attributes on the comments. + permission = "Allow sendto" + + # Unwrapped comments can't find the portal so just return manager + self.assertNotIn("Anonymous", + rolesForPermissionOn(permission, + self.unwrapped_archetypes_comment)) + self.assertNotIn("Anonymous", + rolesForPermissionOn(permission, + self.unwrapped_dexterity_comment)) + + # Wrapped objects can find the portal and correctly return the + # anonymous role. + self.assertIn("Anonymous", + rolesForPermissionOn(permission, + self.wrapped_archetypes_comment)) + self.assertIn("Anonymous", + rolesForPermissionOn(permission, + self.wrapped_dexterity_comment)) + + def test_acquiring_comment_permissions_via_user_nobody(self): + """ The current implementation uses user_nobody.has_permission to + check whether anonymous can view comments. This confirms it also + works. """ + + # Again we want to use a permission that's not managed by any of our + # content objects so it must be acquired from the portal. + permission = "Allow sendto" + + self.assertFalse( + user_nobody.has_permission(permission, + self.unwrapped_archetypes_comment)) + + self.assertFalse( + user_nobody.has_permission(permission, + self.unwrapped_dexterity_comment)) + + self.assertTrue( + user_nobody.has_permission(permission, + self.wrapped_archetypes_comment)) + + self.assertTrue( + user_nobody.has_permission(permission, + self.wrapped_dexterity_comment)) + +class AcquiredPermissionTest(unittest.TestCase): + """ Test methods of a conversation which rely on acquired permissions """ + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer['portal'] + self.request = self.layer['request'] + setRoles(self.portal, TEST_USER_ID, ['Manager']) + self.wftool = getToolByName(self.portal, 'portal_workflow') + + # Disable workflow for comments and content. + self.wftool.setChainForPortalTypes(["Discussion Item"],[]) + self.wftool.setChainForPortalTypes([dexterity_type_name],[]) + + # Create a dexterity item. + self.portal.invokeFactory( + id=dexterity_object_id, + title='Instance Of Dexterity Type', + type_name=dexterity_type_name, + ) + + self.content = self.portal.get(dexterity_object_id) + + # Absolutely make sure that we're replicating the case of an + # incomplete chain correctly. + aq_base(self.content).__parent__ = None + + self.conversation = IConversation(self.content) + + # Add a comment + comment = createObject('plone.Comment') + self.conversation.addComment(comment) + self.comment = comment + + def test_view_permission_is_only_available_on_portal(self): + """ Check that the test setup is correct """ + + content_roles = rolesForPermissionOn("View",aq_base(self.content)) + self.assertNotIn("Anonymous",content_roles) + + comment_roles = rolesForPermissionOn("View",aq_base(self.comment)) + self.assertNotIn("Anonymous",comment_roles) + + # This actually acquires view from the app root, but we don't really + # care, we just need to confirm that something above our content + # object will give us View. + portal_roles = rolesForPermissionOn("View",self.portal) + self.assertIn("Anonymous",portal_roles) + + # The following tests fail when the conversation uses unwrapped comment + # objects to determine whether an anonymous user has the view permission. + + def test_total_comments(self): + self.assertEqual(self.conversation.total_comments,1) + + def test_last_comment_date(self): + self.assertEqual(self.conversation.last_comment_date, + self.comment.creation_date) + + def test_public_commentators(self): + self.assertEqual(self.conversation.public_commentators, + (self.comment.author_username,)) + + + +def test_suite(): + return unittest.defaultTestLoader.loadTestsFromName(__name__)