diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0bb2ac1..0000000 --- a/.travis.yml +++ /dev/null @@ -1,61 +0,0 @@ -dist: bionic -language: python -cache: - pip: true - directories: - - eggs - - $HOME/buildout-cache - - $HOME/.buildout -python: - - "2.7" -matrix: - include: - - python: "2.7" - env: PLONE_VERSION=43 - - python: "2.7" - env: PLONE_VERSION=51 - - python: "2.7" - env: PLONE_VERSION=52 - - python: "3.7" - env: PLONE_VERSION=52 - fast_finish: true - -before_install: - - mkdir -p $HOME/buildout-cache/{downloads,eggs,extends} - - mkdir -p $HOME/.buildout - - echo "[buildout]" > $HOME/.buildout/default.cfg - - echo "download-cache = $HOME/buildout-cache/downloads" >> $HOME/.buildout/default.cfg - - echo "eggs-directory = $HOME/buildout-cache/eggs" >> $HOME/.buildout/default.cfg - - echo "extends-cache = $HOME/buildout-cache/extends" >> $HOME/.buildout/default.cfg - - echo "abi-tag-eggs = true" >> $HOME/.buildout/default.cfg - - git config --global user.email "travis@travis-ci.org" - - git config --global user.name "Travis CI" - - sudo apt-get install -y firefox-geckodriver - - virtualenv -p `which python` . - - bin/pip install -r requirements.txt -c constraints_plone$PLONE_VERSION.txt - - cp test_plone$PLONE_VERSION.cfg buildout.cfg - -install: - - travis_retry pip install -U tox coveralls coverage -c constraints.txt - -before_script: - - 'export DISPLAY=:99.0' - - export VERBOSE=true - - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - - sleep 3 - -script: - - PYTEST_ADDOPTS="-s -vv" tox - -after_success: - - python -m coverage.pickle2json - - coverage combine - - coveralls - -notifications: - email: - recipients: -# - travis-reports@plone.com - - {author} - on_success: change - on_failure: change diff --git a/CHANGES.rst b/CHANGES.rst index 4cb32bb..6d65af8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,8 +5,19 @@ Changelog 1.0.1 (unreleased) ------------------ -- Nothing changed yet. - +- Only managers can access deleted feedbacks. + [cekk] +- Allow all authenticated users to access @feedback endpoint. + The endpoint will return only feedbacks on objects that they can edit. + [cekk] +- Improve tests. + [cekk] +- Install souper.plone to have its control-panel in backend. + [cekk] +- Remove unused user action. + [cekk] +- Add `actions` infos in @feedback endpoint, to let the frontend know what the user can do. + [cekk] 1.0.0 (2023-02-16) ------------------ diff --git a/base.cfg b/base.cfg index efbbe11..80a3979 100644 --- a/base.cfg +++ b/base.cfg @@ -17,6 +17,8 @@ parts = robot plone-helper-scripts vscode + zpretty + zpretty-run develop = . @@ -109,6 +111,20 @@ scripts = zopepy plone-compile-resources +[zpretty] +recipe = zc.recipe.egg +eggs = + zpretty + +[zpretty-run] +recipe = collective.recipe.template +input = inline: + #!/bin/bash + find src -name '*.zcml' | xargs bin/zpretty -i +output = ${buildout:directory}/bin/zpretty-run +mode = 755 + + [versions] # Don't use a released version of collective.feedback collective.feedback = diff --git a/setup.py b/setup.py index c9c2845..55424f6 100644 --- a/setup.py +++ b/setup.py @@ -53,21 +53,23 @@ python_requires=">=3.7", install_requires=[ "setuptools", - # -*- Extra requirements: -*- - "z3c.jbot", - "plone.api>=1.8.4", - "plone.app.dexterity", "souper.plone", + "collective.honeypot>=2.1", ], extras_require={ "test": [ + "gocept.pytestlayer", "plone.app.testing", - # Plone KGS does not use this version, because it would break - # Remove if your package shall be part of coredev. - # plone_coredev tests as of 2016-04-01. - "plone.testing>=5.0.0", - "plone.app.contenttypes", - "plone.app.robotframework[debug]", + "plone.restapi[test]", + "pytest-cov", + "pytest-plone>=0.2.0", + "pytest-docker", + "pytest-mock", + "pytest", + "zest.releaser[recommended]", + "zestreleaser.towncrier", + "pytest-mock", + "requests-mock", ], }, entry_points=""" diff --git a/src/collective/feedback/configure.zcml b/src/collective/feedback/configure.zcml index bcc1465..5e3e5f4 100644 --- a/src/collective/feedback/configure.zcml +++ b/src/collective/feedback/configure.zcml @@ -9,10 +9,12 @@ + + + + + + + diff --git a/src/collective/feedback/profiles/default/actions.xml b/src/collective/feedback/profiles/default/actions.xml deleted file mode 100644 index 75411c0..0000000 --- a/src/collective/feedback/profiles/default/actions.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Feedback dashboard - string:${globals_view/navigationRootUrl}/feedback-dashboard - - - - - - True - - - - diff --git a/src/collective/feedback/profiles/default/metadata.xml b/src/collective/feedback/profiles/default/metadata.xml index d5bd84a..37cad1a 100644 --- a/src/collective/feedback/profiles/default/metadata.xml +++ b/src/collective/feedback/profiles/default/metadata.xml @@ -1,7 +1,8 @@ - 1000 + 1100 - + profile-plone.restapi:default + profile-souper.plone:default diff --git a/src/collective/feedback/profiles/default/rolemap.xml b/src/collective/feedback/profiles/default/rolemap.xml index 2739bf4..cfdb62b 100644 --- a/src/collective/feedback/profiles/default/rolemap.xml +++ b/src/collective/feedback/profiles/default/rolemap.xml @@ -3,7 +3,7 @@ @@ -12,9 +12,19 @@ + + + + + + + + + + diff --git a/src/collective/feedback/profiles/default/to_1100/rolemap.xml b/src/collective/feedback/profiles/default/to_1100/rolemap.xml new file mode 100644 index 0000000..1bad339 --- /dev/null +++ b/src/collective/feedback/profiles/default/to_1100/rolemap.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/collective/feedback/restapi/services/configure.zcml b/src/collective/feedback/restapi/services/configure.zcml index 487ce24..c80e9b3 100644 --- a/src/collective/feedback/restapi/services/configure.zcml +++ b/src/collective/feedback/restapi/services/configure.zcml @@ -17,7 +17,7 @@ method="GET" factory=".get.FeedbackGet" for="plone.app.layout.navigation.interfaces.INavigationRoot" - permission="collective.feedback.AccessFeedbacks" + permission="collective.feedback.FeedbacksOverview" layer="collective.feedback.interfaces.ICollectiveFeedbackLayer" name="@feedback" /> @@ -25,7 +25,7 @@ method="GET" factory=".get.FeedbackGetCSV" for="plone.app.layout.navigation.interfaces.INavigationRoot" - permission="collective.feedback.AccessFeedbacks" + permission="collective.feedback.FeedbacksOverview" layer="collective.feedback.interfaces.ICollectiveFeedbackLayer" name="@feedback-csv" /> @@ -33,7 +33,7 @@ method="DELETE" factory=".delete.FeedbackDelete" for="plone.app.layout.navigation.interfaces.INavigationRoot" - permission="collective.feedback.ManageFeedbacks" + permission="collective.feedback.DeleteFeedbacks" layer="collective.feedback.interfaces.ICollectiveFeedbackLayer" name="@feedback-delete" /> diff --git a/src/collective/feedback/restapi/services/get.py b/src/collective/feedback/restapi/services/get.py index ad1cc70..1cdd527 100644 --- a/src/collective/feedback/restapi/services/get.py +++ b/src/collective/feedback/restapi/services/get.py @@ -23,39 +23,39 @@ def __init__(self, context, request): super().__init__(context, request) self.params = [] - def publishTraverse(self, request, name): + def publishTraverse(self, request, uid): # Consume any path segments after /@users as parameters - self.params.append(name) + self.params.append(uid) return self def reply(self): if self.params: + # single object detail results = self.get_single_object_feedbacks(self.params[0]) - batch = HypermediaBatch(self.request, results) - data = { - "@id": batch.canonical_url, - "items": [self.fix_fields(x, "date") for x in batch], - "items_total": batch.items_total, - } - links = batch.links - if links: - data["batching"] = links else: results = self.get_data() - batch = HypermediaBatch(self.request, results) - data = { - "@id": batch.canonical_url, - "items": [self.fix_fields(x, "last_vote") for x in batch], - "items_total": batch.items_total, - } - links = batch.links - if links: - data["batching"] = links - + batch = HypermediaBatch(self.request, results) + data = { + "@id": batch.canonical_url, + "items": [self.fix_fields(data=x) for x in batch], + "items_total": batch.items_total, + } + links = batch.links + if links: + data["batching"] = links + + data["actions"] = {"can_delete_feedbacks": self.can_delete_feedbacks()} return data - def fix_fields(self, data, param): - data[param] = json_compatible(data[param]) + def can_delete_feedbacks(self): + return api.user.has_permission("collective.feedback: Delete Feedbacks") + + def fix_fields(self, data): + """ + Make data json compatible + """ + for k, v in data.items(): + data[k] = json_compatible(v) return data def parse_query(self): @@ -75,8 +75,10 @@ def parse_query(self): res["query"] = query return res - def get_commented_obj(self, record): - uid = record._attrs.get("uid", "") + def get_commented_obj(self, uid): + """ + Return obj based on uid. + """ try: obj = api.content.get(UID=uid) except Unauthorized: @@ -86,7 +88,7 @@ def get_commented_obj(self, record): return if not api.user.has_permission( - "rer.customersatisfaction: Access Customer Satisfaction", obj=obj + "collective.feedback: Access Feedbacks", obj=obj ): # user does not have that permission on object return @@ -94,6 +96,12 @@ def get_commented_obj(self, record): return obj def get_single_object_feedbacks(self, uid): + """ + Return data for single object + """ + commented_object = self.get_commented_obj(uid=uid) + if not commented_object: + return [] tool = getUtility(ICollectiveFeedbackStore) results = tool.search(query={"uid": uid}) feedbacks = [] @@ -101,12 +109,12 @@ def get_single_object_feedbacks(self, uid): for record in results: feedbacks.append( { - "uid": record._attrs.get("uid", ""), + "uid": uid, "date": record._attrs.get("date", ""), "vote": record._attrs.get("vote", ""), "answer": record._attrs.get("answer", ""), "comment": record._attrs.get("comment", ""), - "title": record._attrs.get("title", ""), + "title": commented_object.title, } ) @@ -127,12 +135,13 @@ def get_data(self): uid = feedback._attrs.get("uid", "") date = feedback._attrs.get("date", "") vote = feedback._attrs.get("vote", "") - if uid not in feedbacks: - obj = self.get_commented_obj(record=feedback) - if not obj: + obj = self.get_commented_obj(uid=uid) + if not obj and not api.user.has_permission( + "collective.feedback: Show Deleted Feedbacks" + ): + # only manager can list deleted object's reviews continue - new_data = { "vote_num": 0, "vote_sum": 0, @@ -212,7 +221,8 @@ def get_data(self): columns = ["title", "url", "vote", "comment", "date", "answer"] for item in tool.search(): - obj = self.get_commented_obj(record=item) + uid = item._attrs.get("uid", "") + obj = self.get_commented_obj(uid=uid) if not obj: continue diff --git a/src/collective/feedback/setuphandlers.py b/src/collective/feedback/setuphandlers.py index e923759..f77ff3f 100644 --- a/src/collective/feedback/setuphandlers.py +++ b/src/collective/feedback/setuphandlers.py @@ -9,6 +9,7 @@ def getNonInstallableProfiles(self): """Hide uninstall profile from site-creation and quickinstaller.""" return [ "collective.feedback:uninstall", + "collective.feedback:to_1100", ] def getNonInstallableProducts(self): diff --git a/src/collective/feedback/testing.py b/src/collective/feedback/testing.py index 3b860ea..a04d653 100644 --- a/src/collective/feedback/testing.py +++ b/src/collective/feedback/testing.py @@ -1,55 +1,52 @@ -# -*- coding: utf-8 -*- -from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE +from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE from plone.app.testing import applyProfile from plone.app.testing import FunctionalTesting from plone.app.testing import IntegrationTesting -from plone.app.testing import PLONE_FIXTURE from plone.app.testing import PloneSandboxLayer -from plone.testing import z2 +from plone.testing.zope import WSGI_SERVER_FIXTURE import collective.feedback -import plone.app.dexterity +import collective.honeypot +import collective.honeypot.config import plone.restapi import souper.plone -class CollectiveFeedbackLayer(PloneSandboxLayer): - defaultBases = (PLONE_FIXTURE,) +collective.honeypot.config.EXTRA_PROTECTED_ACTIONS = set(["feedback-add"]) +collective.honeypot.config.HONEYPOT_FIELD = "honey" + + +class TestLayer(PloneSandboxLayer): + defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,) def setUpZope(self, app, configurationContext): # Load any other ZCML that is required for your tests. # The z3c.autoinclude feature is disabled in the Plone fixture base # layer. - - self.loadZCML(package=plone.app.dexterity) self.loadZCML(package=plone.restapi) - self.loadZCML(package=collective.feedback) + self.loadZCML(package=collective.honeypot) self.loadZCML(package=souper.plone) + self.loadZCML(package=collective.feedback) def setUpPloneSite(self, portal): applyProfile(portal, "collective.feedback:default") -COLLECTIVE_FEEDBACK_FIXTURE = CollectiveFeedbackLayer() +FIXTURE = TestLayer() -COLLECTIVE_FEEDBACK_INTEGRATION_TESTING = IntegrationTesting( - bases=(COLLECTIVE_FEEDBACK_FIXTURE,), +INTEGRATION_TESTING = IntegrationTesting( + bases=(FIXTURE,), name="CollectiveFeedbackLayer:IntegrationTesting", ) -COLLECTIVE_FEEDBACK_FUNCTIONAL_TESTING = FunctionalTesting( - bases=(COLLECTIVE_FEEDBACK_FIXTURE,), +FUNCTIONAL_TESTING = FunctionalTesting( + bases=(FIXTURE,), name="CollectiveFeedbackLayer:FunctionalTesting", ) - -COLLECTIVE_FEEDBACK_ACCEPTANCE_TESTING = FunctionalTesting( - bases=( - COLLECTIVE_FEEDBACK_FIXTURE, - REMOTE_LIBRARY_BUNDLE_FIXTURE, - z2.ZSERVER_FIXTURE, - ), - name="CollectiveFeedbackLayer:AcceptanceTesting", +RESTAPI_TESTING = FunctionalTesting( + bases=(FIXTURE, WSGI_SERVER_FIXTURE), + name="CollectiveFeedbackLayer:RestAPITesting", ) diff --git a/src/collective/feedback/tests/robot/test_example.robot b/src/collective/feedback/tests/robot/test_example.robot deleted file mode 100644 index 93a6c71..0000000 --- a/src/collective/feedback/tests/robot/test_example.robot +++ /dev/null @@ -1,66 +0,0 @@ -# ============================================================================ -# EXAMPLE ROBOT TESTS -# ============================================================================ -# -# Run this robot test stand-alone: -# -# $ bin/test -s collective.feedback -t test_example.robot --all -# -# Run this robot test with robot server (which is faster): -# -# 1) Start robot server: -# -# $ bin/robot-server --reload-path src collective.feedback.testing.COLLECTIVE_FEEDBACK_ACCEPTANCE_TESTING -# -# 2) Run robot tests: -# -# $ bin/robot src/collective/feedback/tests/robot/test_example.robot -# -# See the http://docs.plone.org for further details (search for robot -# framework). -# -# ============================================================================ - -*** Settings ***************************************************************** - -Resource plone/app/robotframework/selenium.robot -Resource plone/app/robotframework/keywords.robot - -Library Remote ${PLONE_URL}/RobotRemote - -Test Setup Open test browser -Test Teardown Close all browsers - - -*** Test Cases *************************************************************** - -Scenario: As a member I want to be able to log into the website - [Documentation] Example of a BDD-style (Behavior-driven development) test. - Given a login form - When I enter valid credentials - Then I am logged in - - -*** Keywords ***************************************************************** - -# --- Given ------------------------------------------------------------------ - -a login form - Go To ${PLONE_URL}/login_form - Wait until page contains Login Name - Wait until page contains Password - - -# --- WHEN ------------------------------------------------------------------- - -I enter valid credentials - Input Text __ac_name admin - Input Text __ac_password secret - Click Button Log in - - -# --- THEN ------------------------------------------------------------------- - -I am logged in - Wait until page contains You are now logged in - Page should contain You are now logged in diff --git a/src/collective/feedback/tests/test_delete_content.py b/src/collective/feedback/tests/test_delete_content.py new file mode 100644 index 0000000..a17a00c --- /dev/null +++ b/src/collective/feedback/tests/test_delete_content.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING +from datetime import datetime +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import RelativeSession +from souper.soup import get_soup +from souper.soup import Record +from zope.component import getUtility + +import transaction +import unittest + + +class TestCustomerSatisfactionGet(unittest.TestCase): + layer = RESTAPI_TESTING + + def add_record(self, date=None, vote="", uid="", comment="", title=""): + if not date: + date = datetime.now() + soup = get_soup("feedback_soup", self.portal) + transaction.commit() + record = Record() + record.attrs["vote"] = vote + record.attrs["date"] = date + + if comment: + record.attrs["comment"] = comment + if uid: + record.attrs["uid"] = uid + if title: + record.attrs["title"] = title + soup.add(record) + transaction.commit() + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + self.url = "{}/@feedback".format(self.portal_url) + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.document1 = api.content.create( + title="document 1", container=self.portal, type="Document" + ) + self.document2 = api.content.create( + title="document 1", container=self.portal, type="Document" + ) + transaction.commit() + + # add some reviews + tool = getUtility(ICollectiveFeedbackStore) + tool.add( + { + "vote": 1, + "uid": self.document1.UID(), + "title": self.document1.title, + } + ) + tool.add( + { + "vote": 2, + "uid": self.document1.UID(), + "title": self.document1.title, + } + ) + tool.add( + { + "vote": 3, + "uid": self.document2.UID(), + "title": self.document2.title, + } + ) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + transaction.commit() + + def tearDown(self): + self.api_session.close() + soup = get_soup("feedback_soup", self.portal) + soup.clear() + + def test_deleting_a_content_does_not_remove_entries(self): + response = self.api_session.get(self.url) + res = response.json() + self.assertEqual(res["items_total"], 2) + + api.content.delete(obj=self.document1) + transaction.commit() + + response = self.api_session.get(self.url) + res = response.json() + self.assertEqual(res["items_total"], 2) diff --git a/src/collective/feedback/tests/test_feedbacks_add.py b/src/collective/feedback/tests/test_feedbacks_add.py new file mode 100644 index 0000000..5d20231 --- /dev/null +++ b/src/collective/feedback/tests/test_feedbacks_add.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.testing import RelativeSession +from zope.component import getUtility + +import transaction +import unittest + + +class TestAdd(unittest.TestCase): + layer = RESTAPI_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + api.user.create( + email="memberuser@example.com", + username="memberuser", + password="secret!!", + ) + + self.document = api.content.create( + title="Document", container=self.portal, type="Document" + ) + api.content.transition(obj=self.document, transition="publish") + + self.private_document = api.content.create( + title="restricted document", container=self.portal, type="Document" + ) + transaction.commit() + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.url = "{}/@feedback-add".format(self.document.absolute_url()) + self.url_private_document = "{}/@feedback-add".format( + self.private_document.absolute_url() + ) + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + def test_required_params(self): + """ """ + # vote is required + res = self.anon_api_session.post(self.url, json={"honey": ""}) + self.assertEqual(res.status_code, 400) + self.assertEqual(res.json()["message"], "Campo obbligatorio mancante: vote") + + def test_correctly_save_data(self): + self.anon_api_session.post( + self.url, + json={"vote": 3, "comment": "i disagree", "honey": ""}, + ) + transaction.commit() + tool = getUtility(ICollectiveFeedbackStore) + self.assertEqual(len(tool.search()), 1) + + # Anonymous cannot vote without access to document + self.anon_api_session.post( + self.url_private_document, + json={"vote": 2, "comment": "i disagree", "honey": ""}, + ) + transaction.commit() + # Number of results did not increase, cause user is unauthorized to vote + self.assertEqual(len(tool.search()), 1) + + def test_store_only_known_fields(self): + self.anon_api_session.post( + self.url, + json={ + "vote": "nok", + "comment": "i disagree", + "unknown": "mistery", + "honey": "", + }, + ) + transaction.commit() + tool = getUtility(ICollectiveFeedbackStore) + res = tool.search() + self.assertEqual(len(res), 1) + self.assertEqual(res[0]._attrs.get("unknown", None), None) + self.assertEqual(res[0]._attrs.get("vote", None), "nok") + self.assertEqual(res[0]._attrs.get("comment", None), "i disagree") + + def test_honeypot_is_required(self): + res = self.anon_api_session.post(self.url, json={}) + self.assertEqual(res.status_code, 403) + + res = self.anon_api_session.post(self.url, json={"vote": "ok"}) + self.assertEqual(res.status_code, 403) + + # HONEYPOT_FIELD is set in testing.py + + res = self.anon_api_session.post(self.url, json={"vote": "ok", "honey": ""}) + self.assertEqual(res.status_code, 204) + + # this is compiled by a bot + res = self.anon_api_session.post( + self.url, json={"vote": "ok", "honey": "i'm a bot"} + ) + self.assertEqual(res.status_code, 403) diff --git a/src/collective/feedback/tests/test_feedbacks_get.py b/src/collective/feedback/tests/test_feedbacks_get.py new file mode 100644 index 0000000..6f413e0 --- /dev/null +++ b/src/collective/feedback/tests/test_feedbacks_get.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING +from datetime import datetime +from plone import api +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.restapi.serializer.converters import json_compatible +from plone.restapi.testing import RelativeSession +from souper.soup import get_soup +from souper.soup import Record +from zope.component import getUtility + +import transaction +import unittest + + +class TestGet(unittest.TestCase): + layer = RESTAPI_TESTING + + def add_record(self, date=None, vote="", uid="", comment="", title=""): + if not date: + date = datetime.now() + soup = get_soup("feedback_soup", self.portal) + transaction.commit() + record = Record() + record.attrs["vote"] = vote + record.attrs["date"] = date + + if comment: + record.attrs["comment"] = comment + if uid: + record.attrs["uid"] = uid + if title: + record.attrs["title"] = title + soup.add(record) + transaction.commit() + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + self.url = "{}/@feedback".format(self.portal_url) + self.tool = getUtility(ICollectiveFeedbackStore) + + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + api.user.create( + email="member@example.com", + username="member", + password="secret!!", + ) + api.user.create( + email="global@example.com", + username="global", + password="secret!!", + ) + + api.user.create( + email="local@example.com", + username="local", + password="secret!!", + ) + # create some contents + self.document = api.content.create( + title="document", container=self.portal, type="Document" + ) + api.content.transition(obj=self.document, transition="publish") + + self.restricted_document = api.content.create( + title="restricted document", container=self.portal, type="Document" + ) + + transaction.commit() + + api.user.grant_roles( + username="global", + roles=["Editor"], + ) + api.user.grant_roles( + username="local", roles=["Editor"], obj=self.restricted_document + ) + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + transaction.commit() + + def tearDown(self): + self.api_session.close() + self.tool.clear() + + def test_anon_cant_access_endpoint(self): + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + self.assertEqual(api_session.get(self.url).status_code, 401) + + def test_admin_can_access_endpoint(self): + self.assertEqual(self.api_session.get(self.url).status_code, 200) + + def test_other_users_can_access_endpoint(self): + for username in ["member", "local", "global"]: + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = (username, "secret!!") + self.assertEqual(api_session.get(self.url).status_code, 200) + + def test_endpoint_returns_data(self): + response = self.api_session.get(self.url) + res = response.json() + self.assertEqual(res["items_total"], 0) + now = datetime.now() + self.add_record(vote=1, comment="is ok", date=now) + + response = self.api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 1) + self.assertEqual( + res["items"], + [ + { + "comments": 1, + "last_vote": json_compatible(now), + "title": "", + "uid": "", + "vote": 1.0, + } + ], + ) + + def test_basic_user_cant_see_data(self): + now = datetime.now() + self.add_record( + vote=1, date=now, comment="is ok for member", uid=self.document.UID() + ) + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = ("member", "secret!!") + + response = api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 0) + + def test_global_editor_can_see_all_data(self): + now = datetime.now() + self.add_record( + vote=1, date=now, comment="is ok for global", uid=self.document.UID() + ) + self.add_record( + vote=1, + date=now, + comment="ok also for restricted", + uid=self.restricted_document.UID(), + ) + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = ("global", "secret!!") + + response = api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 2) + + def test_local_editor_can_see_only_data_for_his_contents(self): + now = datetime.now() + self.add_record( + vote=1, date=now, comment="is ok for global", uid=self.document.UID() + ) + self.add_record( + vote=1, + date=now, + comment="ok also for restricted", + uid=self.restricted_document.UID(), + ) + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = ("local", "secret!!") + + response = api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 1) + self.assertEqual(res["items"][0]["uid"], self.restricted_document.UID()) + + def test_only_admins_can_see_deleted_contents(self): + now = datetime.now() + self.add_record(vote=1, date=now, comment="is ok", uid=self.document.UID()) + self.add_record( + vote=1, + date=now, + comment="ok for deleted content", + uid="qwertyuiop", + ) + + response = self.api_session.get(self.url) + res = response.json() + self.assertEqual(res["items_total"], 2) + + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = ("global", "secret!!") + + response = api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 1) + + def test_actions_list_returned(self): + response = self.api_session.get(self.url) + res = response.json() + self.assertIn("actions", res) + + def test_users_with_permission_have_can_delete_feedbacks_action(self): + response = self.api_session.get(self.url) + res = response.json() + self.assertIn("can_delete_feedbacks", res["actions"]) + self.assertTrue(res["actions"]["can_delete_feedbacks"]) + + def test_users_without_permission_dont_have_can_delete_feedbacks_action(self): + api_session = RelativeSession(self.portal_url) + api_session.headers.update({"Accept": "application/json"}) + api_session.auth = ("global", "secret!!") + + response = api_session.get(self.url) + res = response.json() + + self.assertIn("can_delete_feedbacks", res["actions"]) + self.assertFalse(res["actions"]["can_delete_feedbacks"]) diff --git a/src/collective/feedback/tests/test_robot.py b/src/collective/feedback/tests/test_robot.py deleted file mode 100644 index 6e1f6bb..0000000 --- a/src/collective/feedback/tests/test_robot.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -from collective.feedback.testing import ( # noqa: E501 - COLLECTIVE_FEEDBACK_ACCEPTANCE_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) - robottestsuite.level = ROBOT_TEST_LEVEL - suite.addTests( - [ - layered( - robottestsuite, - layer=COLLECTIVE_FEEDBACK_ACCEPTANCE_TESTING, - ), - ] - ) - return suite diff --git a/src/collective/feedback/tests/test_setup.py b/src/collective/feedback/tests/test_setup.py deleted file mode 100644 index 1bcdc71..0000000 --- a/src/collective/feedback/tests/test_setup.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -"""Setup tests for this package.""" -from collective.feedback.testing import ( # noqa: E501 - COLLECTIVE_FEEDBACK_INTEGRATION_TESTING, -) -from plone import api -from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID - -import unittest - - -try: - from Products.CMFPlone.utils import get_installer -except ImportError: - get_installer = None - - -class TestSetup(unittest.TestCase): - """Test that collective.feedback is properly installed.""" - - layer = COLLECTIVE_FEEDBACK_INTEGRATION_TESTING - - def setUp(self): - """Custom shared utility setup for tests.""" - self.portal = self.layer["portal"] - if get_installer: - self.installer = get_installer(self.portal, self.layer["request"]) - else: - self.installer = api.portal.get_tool("portal_quickinstaller") - - def test_product_installed(self): - """Test if collective.feedback is installed.""" - self.assertTrue(self.installer.is_product_installed("collective.feedback")) - - def test_browserlayer(self): - """Test that ICollectiveFeedbackLayer is registered.""" - from collective.feedback.interfaces import ICollectiveFeedbackLayer - from plone.browserlayer import utils - - self.assertIn(ICollectiveFeedbackLayer, utils.registered_layers()) - - -class TestUninstall(unittest.TestCase): - layer = COLLECTIVE_FEEDBACK_INTEGRATION_TESTING - - def setUp(self): - self.portal = self.layer["portal"] - if get_installer: - self.installer = get_installer(self.portal, self.layer["request"]) - else: - self.installer = api.portal.get_tool("portal_quickinstaller") - roles_before = api.user.get_roles(TEST_USER_ID) - setRoles(self.portal, TEST_USER_ID, ["Manager"]) - self.installer.uninstall_product("collective.feedback") - setRoles(self.portal, TEST_USER_ID, roles_before) - - def test_product_uninstalled(self): - """Test if collective.feedback is cleanly uninstalled.""" - self.assertFalse(self.installer.is_product_installed("collective.feedback")) - - def test_browserlayer_removed(self): - """Test that ICollectiveFeedbackLayer is removed.""" - from collective.feedback.interfaces import ICollectiveFeedbackLayer - from plone.browserlayer import utils - - self.assertNotIn(ICollectiveFeedbackLayer, utils.registered_layers()) diff --git a/src/collective/feedback/tests/test_store.py b/src/collective/feedback/tests/test_store.py index 815d2a7..1ffaffa 100644 --- a/src/collective/feedback/tests/test_store.py +++ b/src/collective/feedback/tests/test_store.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from collective.feedback.interfaces import ICollectiveFeedbackStore -from collective.feedback.testing import COLLECTIVE_FEEDBACK_FUNCTIONAL_TESTING +from collective.feedback.testing import FUNCTIONAL_TESTING from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from zope.component import getUtility @@ -10,7 +10,7 @@ class TestTool(unittest.TestCase): - layer = COLLECTIVE_FEEDBACK_FUNCTIONAL_TESTING + layer = FUNCTIONAL_TESTING def setUp(self): self.app = self.layer["app"] diff --git a/src/collective/feedback/upgrades.py b/src/collective/feedback/upgrades.py new file mode 100644 index 0000000..757c8b4 --- /dev/null +++ b/src/collective/feedback/upgrades.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from plone import api +from plone.app.upgrade.utils import installOrReinstallProduct + +import logging + + +logger = logging.getLogger(__name__) + +DEFAULT_PROFILE = "profile-collective.feedback:default" + + +def update_profile(context, profile, run_dependencies=True): + context.runImportStepFromProfile(DEFAULT_PROFILE, profile, run_dependencies) + + +def update_types(context): + update_profile(context, "typeinfo") + + +def update_rolemap(context): + update_profile(context, "rolemap") + + +def update_registry(context): + update_profile(context, "plone.app.registry", run_dependencies=False) + + +def update_catalog(context): + update_profile(context, "catalog") + + +def update_controlpanel(context): + update_profile(context, "controlpanel") + + +def to_1100(context): + installOrReinstallProduct(api.portal.get(), "souper.plone") + context.runAllImportStepsFromProfile("profile-collective.feedback:to_1100") + + # remove broken action + if "feedback-dashboard" in context.portal_actions.user: + del context.portal_actions.user["feedback-dashboard"] diff --git a/src/collective/feedback/upgrades.zcml b/src/collective/feedback/upgrades.zcml new file mode 100644 index 0000000..cc7e2c1 --- /dev/null +++ b/src/collective/feedback/upgrades.zcml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/test_plone60.cfg b/test_plone60.cfg index 7dc9271..12e1d6f 100644 --- a/test_plone60.cfg +++ b/test_plone60.cfg @@ -54,3 +54,41 @@ plumber = 1.7 # Required by: # souper==1.1.1 node.ext.zodb = 1.5 + +# Added by buildout at 2024-02-08 15:13:37.168123 +build = 1.0.3 +check-manifest = 0.49 +pep440 = 0.1.2 +pyproject-hooks = 1.0.0 +pyroma = 4.2 +pytest = 7.4.3 +pytest-cov = 4.1.0 +pytest-docker = 2.0.1 +pytest-mock = 3.12.0 +pytest-plone = 0.2.0 +requests-mock = 1.11.0 +towncrier = 23.6.0 +trove-classifiers = 2023.8.7 +zestreleaser.towncrier = 1.3.0 + +# Required by: +# towncrier==23.6.0 +click-default-group = 1.2.4 + +# Required by: +# pytest-plone==0.2.0 +gocept.pytestlayer = 8.1 + +# Required by: +# towncrier==23.6.0 +incremental = 22.10.0 + +# Required by: +# pytest==7.4.3 +iniconfig = 2.0.0 + +# Added by buildout at 2024-02-08 16:09:12.800009 +collective.honeypot = 2.1 + +# Added by buildout at 2024-02-08 21:49:12.973900 +zpretty = 3.1.0