diff --git a/cms/dashboard/permissions.py b/cms/dashboard/permissions.py new file mode 100644 index 000000000..2635affb7 --- /dev/null +++ b/cms/dashboard/permissions.py @@ -0,0 +1,29 @@ +from cms.auth_content.constants import WILDCARD_ID_VALUE + + +def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: + if not isinstance(user_permissions, list): + return False + + for permission in user_permissions: + permission_theme_id = permission.get("theme", {}).get("id") + permission_sub_theme_id = permission.get("sub_theme", {}).get("id") + permission_topic_id = permission.get("topic", {}).get("id") + + if permission_theme_id == WILDCARD_ID_VALUE: + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == WILDCARD_ID_VALUE + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) + ): + return True + + return False \ No newline at end of file diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 9bb4669fa..887f7fe9b 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -10,7 +10,7 @@ from caching.private_api.decorators import cache_response from cms.auth_content.auth_utils import is_auth_enabled -from cms.auth_content.constants import WILDCARD_ID_VALUE +from cms.dashboard.permissions import check_permissions from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage @@ -19,34 +19,6 @@ AUTH_ENABLED = is_auth_enabled() -def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: - if not isinstance(user_permissions, list): - return False - - for permission in user_permissions: - permission_theme_id = permission.get("theme", {}).get("id") - permission_sub_theme_id = permission.get("sub_theme", {}).get("id") - permission_topic_id = permission.get("topic", {}).get("id") - - if permission_theme_id == WILDCARD_ID_VALUE: - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == WILDCARD_ID_VALUE - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) - ): - return True - - return False - - @extend_schema(tags=["cms"]) class CMSPagesAPIViewSet(PagesAPIViewSet): # This is the /pages (or proxy/pages env dependent endpoint) diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index e57afd8ba..051122f4a 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -2,6 +2,7 @@ from django.db import models from wagtail import blocks from wagtail.blocks import ( + BooleanBlock, CharBlock, ChoiceBlock, PageChooserBlock, @@ -13,6 +14,8 @@ ) from wagtail.snippets.blocks import SnippetChooserBlock + +from cms.dashboard.permissions import check_permissions from cms.dynamic_content import help_texts from cms.dynamic_content.components import ( HeadlineNumberComponent, @@ -72,7 +75,6 @@ class PageLinkChooserBlock(PageChooserBlock): def get_api_representation(cls, value, context=None) -> str | None: if value: return value.full_url - return None @@ -206,6 +208,44 @@ class PageLink(StructBlock): ) page = PageLinkChooserBlock(target_model=["topic.TopicPage"]) + def get_api_representation(self, value, context=None): + data = super().get_api_representation(value, context) + page = value.get("page") + + if not page: + data["is_authorised"] = False + data["title"] = "" + data["sub_title"] = "" + data["page"] = "" + return data + + page = page.specific + request = context.get("request") if context else None + user = getattr(request, "user", None) + + + if not page.is_public: + user_permissions = getattr(user, "permission_sets", None) + full_user_permissions = ( + user_permissions.permission_sets.get("permission_sets") + if user_permissions and hasattr(user_permissions, "permission_sets") + else None + ) + if not check_permissions( + full_user_permissions, + getattr(page, "theme", None), + getattr(page, "sub_theme", None), + getattr(page, "topic", None), + ): + data["is_authorised"] = False + data["title"] = "" + data["sub_title"] = "" + data["page"] = "" + return data + + data["is_authorised"] = True + return data + class InternalPageLinks(StreamBlock): page_link = PageLink() diff --git a/tests/unit/cms/dashboard/test_viewsets.py b/tests/unit/cms/dashboard/test_viewsets.py index 165ccb266..a436a79c1 100644 --- a/tests/unit/cms/dashboard/test_viewsets.py +++ b/tests/unit/cms/dashboard/test_viewsets.py @@ -1,10 +1,10 @@ import pytest +from cms.dashboard.permissions import check_permissions from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer from cms.dashboard.viewsets import ( CMSDraftPagesViewSet, CMSPagesAPIViewSet, - check_permissions, ) diff --git a/tests/unit/cms/dynamic_content/test_blocks.py b/tests/unit/cms/dynamic_content/test_blocks.py index fe4fb9d79..734be9348 100644 --- a/tests/unit/cms/dynamic_content/test_blocks.py +++ b/tests/unit/cms/dynamic_content/test_blocks.py @@ -6,7 +6,7 @@ import pytest from wagtail.blocks import StructBlock, StructValue -from cms.dynamic_content.blocks import SourceLinkBlock +from cms.dynamic_content.blocks import PageLink, SourceLinkBlock class TestSourceLinkBlockClean: @@ -133,3 +133,173 @@ def test_does_not_raise_when_only_external_url_set(self): ) SourceLinkBlock._validate_only_one_of_page_or_external_url(value=value) + + +class TestPageLinkBlock: + """Tests for PageLink.get_api_representation().""" + + def test_no_page_returns_unauthorised(self): + """ + Given a value with no page set + When get_api_representation() is called + Then the response is unauthorised and fields are blanked. + """ + block = PageLink() + value = { + "title": "Test title", + "sub_title": "Test subtitle", + "page": None, + } + + result = block.get_api_representation(value=value, context={}) + + assert result["is_authorised"] is False + assert result["title"] == "" + assert result["sub_title"] == "" + assert result["page"] == "" + + def test_public_page_is_always_authorised(self): + """ + Given a public page + When get_api_representation() is called + Then the response is authorised and fields are preserved. + """ + block = PageLink() + + mock_page = mock.MagicMock() + mock_page.specific = mock_page + mock_page.is_public = True + + value = { + "title": "Test title", + "sub_title": "Test subtitle", + "page": mock_page, + } + + result = block.get_api_representation(value=value, context={}) + + assert result["is_authorised"] is True + assert result["title"] == "Test title" + assert result["sub_title"] == "Test subtitle" + + @mock.patch("cms.dynamic_content.blocks.check_permissions") + def test_non_public_page_permission_denied(self, mock_check_permissions): + """ + Given a non-public page and permissions denied + When get_api_representation() is called + Then the response is unauthorised and fields are blanked. + """ + mock_check_permissions.return_value = False + + block = PageLink() + + mock_page = mock.MagicMock() + mock_page.specific = mock_page + mock_page.is_public = False + mock_page.theme = 1 + mock_page.sub_theme = 2 + mock_page.topic = 3 + + mock_user = mock.MagicMock() + mock_user.permission_sets = mock.MagicMock() + mock_user.permission_sets.permission_sets = { + "permission_sets": [] + } + + mock_request = mock.MagicMock() + mock_request.user = mock_user + + value = { + "title": "Test title", + "sub_title": "Test subtitle", + "page": mock_page, + } + + result = block.get_api_representation( + value=value, context={"request": mock_request} + ) + + assert result["is_authorised"] is False + assert result["title"] == "" + assert result["sub_title"] == "" + assert result["page"] == "" + + mock_check_permissions.assert_called_once() + + @mock.patch("cms.dynamic_content.blocks.check_permissions") + def test_non_public_page_permission_granted(self, mock_check_permissions): + """ + Given a non-public page and permissions granted + When get_api_representation() is called + Then the response is authorised and fields are preserved. + """ + mock_check_permissions.return_value = True + + block = PageLink() + + mock_page = mock.MagicMock() + mock_page.specific = mock_page + mock_page.is_public = False + mock_page.theme = 1 + mock_page.sub_theme = 2 + mock_page.topic = 3 + mock_page.full_url = "http://test-page-url" + + mock_user = mock.MagicMock() + mock_user.permission_sets = mock.MagicMock() + mock_user.permission_sets.permission_sets = { + "permission_sets": [] + } + + mock_request = mock.MagicMock() + mock_request.user = mock_user + + value = { + "title": "Test title", + "sub_title": "Test subtitle", + "page": mock_page, + } + + result = block.get_api_representation( + value=value, context={"request": mock_request} + ) + + assert result["is_authorised"] is True + assert result["title"] == "Test title" + assert result["sub_title"] == "Test subtitle" + assert result["page"] == "http://test-page-url" + + mock_check_permissions.assert_called_once() + + @mock.patch("cms.dynamic_content.blocks.check_permissions") + def test_non_public_page_missing_request(self, mock_check_permissions): + """ + Given a non-public page without request context + When get_api_representation() is called + Then the response is unauthorised and fields are blanked. + """ + mock_check_permissions.return_value = False + + block = PageLink() + + mock_page = mock.MagicMock() + mock_page.specific = mock_page + mock_page.is_public = False + mock_page.theme = 1 + mock_page.sub_theme = 2 + mock_page.topic = 3 + + value = { + "title": "Test title", + "sub_title": "Test subtitle", + "page": mock_page, + } + + result = block.get_api_representation(value=value, context={}) + + assert result["is_authorised"] is False + assert result["title"] == "" + assert result["sub_title"] == "" + assert result["page"] == "" + + mock_check_permissions.assert_called_once() \ No newline at end of file