dynamicweb/cms/tests/test_plugin_renderers.py

597 lines
26 KiB
Python

from collections import deque
from django.template import Context
from django.test.utils import override_settings
from cms.api import add_plugin, create_page
from cms.models import CMSPlugin, Placeholder
from cms.plugin_rendering import (
ContentRenderer,
LegacyRenderer,
StructureRenderer,
)
from cms.test_utils.testcases import CMSTestCase
from cms.test_utils.util.context_managers import override_placeholder_conf
from cms.toolbar.toolbar import CMSToolbar
from cms.toolbar.utils import get_object_edit_url
class TestStructureRenderer(CMSTestCase):
renderer_class = StructureRenderer
def get_renderer(self, path=None, language='en', page=None):
if page and path is None:
path = page.get_absolute_url(language)
request = self.get_request(path, language, page=page)
return self.renderer_class(request)
def test_get_plugin_class_cache(self):
plugin = CMSPlugin(plugin_type='MultiColumnPlugin')
renderer = self.get_renderer()
plugin_class = renderer.get_plugin_class(plugin)
self.assertIn('MultiColumnPlugin', renderer._cached_plugin_classes)
self.assertEqual(plugin_class.__name__, 'MultiColumnPlugin')
self.assertEqual(renderer._cached_plugin_classes['MultiColumnPlugin'], plugin_class)
def test_get_placeholder_plugin_menu(self):
cms_page = create_page("page", 'nav_playground.html', "en")
superuser = self.get_superuser()
placeholder_1 = cms_page.get_placeholders("en").get(slot='body')
with self.login_user_context(superuser):
renderer = self.get_renderer()
plugin_menu = renderer.get_placeholder_plugin_menu(placeholder_1)
expected = '<div class="cms-submenu-item cms-submenu-item-title"><span>Multi Columns</span></div>'
self.assertTrue(expected in plugin_menu)
def test_render_placeholder_toolbar_js(self):
cms_page = create_page("page", 'nav_playground.html', "en")
renderer = self.get_renderer()
placeholder = cms_page.get_placeholders("en").get(slot='body')
content = renderer.get_placeholder_toolbar_js(placeholder)
expected_bits = [
'"MultiColumnPlugin"',
'"addPluginHelpTitle": "Add plugin to placeholder \\"Body\\""',
'"name": "Body"',
f'"placeholder_id": "{placeholder.pk}"',
]
for bit in expected_bits:
self.assertIn(bit, content)
def test_render_placeholder_toolbar_js_escaping(self):
cms_page = create_page("page", 'nav_playground.html', "en")
renderer = self.get_renderer()
placeholder = cms_page.get_placeholders("en").get(slot='body')
conf = {placeholder.slot: {'name': 'Content-with-dash'}}
with override_placeholder_conf(CMS_PLACEHOLDER_CONF=conf):
content = renderer.get_placeholder_toolbar_js(placeholder)
expected_bits = [
'"MultiColumnPlugin"',
'"addPluginHelpTitle": "Add plugin to placeholder \\"Content-with-dash\\""',
'"name": "Content-with-dash"',
f'"placeholder_id": "{placeholder.pk}"',
]
for bit in expected_bits:
self.assertIn(bit, content)
class TestContentRenderer(TestStructureRenderer):
renderer_class = ContentRenderer
@override_settings(CMS_PLACEHOLDER_CACHE=True)
def test_placeholder_cache_enabled(self):
cms_page = create_page("page", 'nav_playground.html', "en")
request_path = cms_page.get_absolute_url('en')
renderer = self.get_renderer(request_path, page=cms_page)
self.assertTrue(renderer.placeholder_cache_is_enabled())
@override_settings(CMS_PLACEHOLDER_CACHE=True)
def test_placeholder_cache_disabled(self):
with override_settings(CMS_PLACEHOLDER_CACHE=False):
# Placeholder cache has been explicitly disabled
renderer = self.get_renderer()
self.assertFalse(renderer.placeholder_cache_is_enabled())
with self.login_user_context(self.get_staff_user_with_no_permissions()):
# Placeholder cache is disabled for all staff users
renderer = self.get_renderer()
self.assertFalse(renderer.placeholder_cache_is_enabled())
def test_preload_placeholders_for_page_with_inherit_off(self):
cms_page = create_page("page", 'nav_playground.html', "en")
placeholder_1 = cms_page.get_placeholders("en").get(slot='body')
placeholder_1_plugin_1 = add_plugin(
placeholder_1,
plugin_type='LinkPlugin',
language='en',
name='Link #1',
external_link='https://www.django-cms.org',
)
placeholder_1_plugin_2 = add_plugin(
placeholder_1,
plugin_type='LinkPlugin',
language='en',
name='Link #2',
external_link='https://www.django-cms.org',
)
placeholder_2 = cms_page.get_placeholders("en").get(slot='right-column')
placeholder_2_plugin_1 = add_plugin(
placeholder_2,
plugin_type='LinkPlugin',
language='en',
name='Link #3',
external_link='https://www.django-cms.org',
)
renderer = self.get_renderer(page=cms_page)
renderer._preload_placeholders_for_page(cms_page)
self.assertIn(cms_page.pk, renderer._placeholders_by_page_cache)
self.assertIn(placeholder_1.slot, renderer._placeholders_by_page_cache[cms_page.pk])
self.assertIn(placeholder_2.slot, renderer._placeholders_by_page_cache[cms_page.pk])
cache = renderer._placeholders_by_page_cache[cms_page.pk]
self.assertEqual(cache[placeholder_1.slot], placeholder_1)
self.assertEqual(
cache[placeholder_1.slot]._plugins_cache, deque([placeholder_1_plugin_1, placeholder_1_plugin_2])
)
self.assertEqual(cache[placeholder_2.slot], placeholder_2)
self.assertEqual(cache[placeholder_2.slot]._plugins_cache, deque([placeholder_2_plugin_1]))
class TestExceptionCatchers(CMSTestCase):
renderer_class = ContentRenderer
def setUp(self):
self.user = self.get_superuser()
self.cms_page = create_page("page", 'nav_playground.html', "en")
self.placeholder_1 = self.cms_page.get_placeholders("en").get(slot='body')
add_plugin(
self.placeholder_1,
plugin_type='LinkPlugin',
language='en',
name='Link #1',
external_link='https://www.django-cms.org',
)
add_plugin(
self.placeholder_1,
plugin_type='BuggyPlugin',
language='en',
)
self.placeholder_2 = self.cms_page.get_placeholders("en").get(slot='right-column')
self.placeholder_2_plugin_1 = add_plugin(
self.placeholder_2,
plugin_type='LinkPlugin',
language='en',
name='Link #3',
external_link='https://www.django-cms.org',
)
self.request = self.get_request(
get_object_edit_url(self.cms_page.get_admin_content('en')),
'en',
page=self.cms_page,
)
self.request.toolbar = CMSToolbar(self.request)
self.renderer = self.renderer_class(self.request)
def test_non_existent_plugins_creates_error_log_and_does_not_fail(self):
self.placeholder_2_plugin_1.plugin_type = 'NonExistingPlugin'
self.placeholder_2_plugin_1.save()
with self.assertLogs("cms.utils.plugins", level="ERROR") as logs:
plugin_context = Context()
self.renderer.render_placeholder(self.placeholder_2, plugin_context, "en")
self.assertEqual(len(logs.output), 1)
self.assertIn("Plugin not installed: NonExistingPlugin", logs.output[0])
self.placeholder_2_plugin_1.plugin_type = 'LinkPlugin'
self.placeholder_2_plugin_1.save()
def test_exception_in_plugin_render_creates_error_log_and_does_not_fail(self):
with self.assertLogs("cms.plugin_rendering", level="ERROR") as logs:
plugin_context = Context()
self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en")
self.assertEqual(len(logs.output), 1)
self.assertIn("ZeroDivisionError: division by zero", logs.output[0])
def test_exception_in_template_rendering_creates_error_log_and_does_not_fail(self):
from cms.test_utils.project.pluginapp.plugins.link.cms_plugins import (
LinkPlugin,
)
link_template = LinkPlugin.render_template
LinkPlugin.render_template = "pluginapp/link/bugs.html"
with self.assertLogs("cms.plugin_rendering", level="ERROR") as logs:
plugin_context = Context()
self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en")
self.assertEqual(len(logs.output), 1)
self.assertIn("TemplateSyntaxError", logs.output[0])
self.assertIn("Invalid block tag on line 1", logs.output[0])
LinkPlugin.render_template = link_template
def test_exception_in_plugin_render_shows_error_in_edit_mode(self):
plugin_context = Context()
with self.assertLogs("cms.plugin_rendering", level="ERROR"):
markup = self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en", editable=True)
self.assertTrue(self.renderer.toolbar.edit_mode_active)
self.assertIn('<div class="cms-rendering-exception">', markup)
self.assertIn("ZeroDivisionError", markup)
self.assertIn("division by zero", markup)
def test_exception_in_plugin_render_escapes_user_content_in_edit_mode(self):
# The exception heading interpolates user-controlled data such as the
# object title (``str(placeholder.source)``). It must be HTML-escaped
# before being returned as safe markup, otherwise a stored payload
# would execute in a staff user's browser in edit mode.
payload = '<img src=x onerror=alert(1)>'
page_content = self.cms_page.get_admin_content('en')
page_content.title = payload
page_content.save()
# Re-fetch the placeholder so its (generic) ``source`` is reloaded with
# the updated title rather than the value cached during ``setUp``.
placeholder = Placeholder.objects.get(pk=self.placeholder_1.pk)
plugin_context = Context()
with self.assertLogs("cms.plugin_rendering", level="ERROR"):
markup = self.renderer.render_placeholder(placeholder, plugin_context, "en", editable=True)
self.assertIn('<div class="cms-rendering-exception">', markup)
self.assertNotIn(payload, markup)
self.assertIn('&lt;img src=x onerror=alert(1)&gt;', markup)
def test_exception_in_plugin_render_is_silent_in_preview_mode(self):
plugin_context = Context()
with self.assertLogs("cms.plugin_rendering", level="ERROR"):
markup = self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en", editable=False)
self.assertEqual('', markup)
def test_exception_in_plugin_render_is_silent_in_live_mode(self):
plugin_context = Context()
self.request = self.get_request(
self.cms_page.get_absolute_url('en'),
'en',
page=self.cms_page,
)
self.request.toolbar = CMSToolbar(self.request)
self.renderer = self.renderer_class(self.request)
with self.assertLogs("cms.plugin_rendering", level="ERROR"):
markup = self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en", editable=False)
self.assertFalse(self.renderer.toolbar.edit_mode_active)
self.assertEqual('', markup)
@override_settings(CMS_CATCH_PLUGIN_500_EXCEPTION=False)
def test_exception_in_plugin_render_is_raised__in_live_mode(self):
plugin_context = Context()
self.request = self.get_request(
self.cms_page.get_absolute_url('en'),
'en',
page=self.cms_page,
)
self.request.toolbar = CMSToolbar(self.request)
self.renderer = self.renderer_class(self.request)
with self.assertRaises(ZeroDivisionError):
with self.assertLogs("cms.plugin_rendering", level="ERROR"):
self.renderer.render_placeholder(self.placeholder_1, plugin_context, "en", editable=False)
class TestLegacyRenderer(TestContentRenderer):
renderer_class = LegacyRenderer
class TestLegacyRendererExceptionCatcher(TestExceptionCatchers):
renderer_class = LegacyRenderer
class TestValidModelsFiltering(CMSTestCase):
"""Tests that get_placeholder_toolbar_js only lists plugins valid for the placeholder's source model.
We create a placeholder on a non-page model (Example1) and register two temporary plugins:
- AllowedExamplePlugin: allowed_models explicitly lists the Example1 model.
- DisallowedExamplePlugin: allowed_models lists a different model path.
The toolbar js should include AllowedExamplePlugin but exclude DisallowedExamplePlugin.
"""
def setUp(self):
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
self.example = Example1.objects.create(
char_1="ex1", char_2="ex2", char_3="ex3", char_4="ex4", publish=True
)
self.placeholder = self.example.placeholder
# dynamic registration of two plugins with differing allowed_models
class AllowedExamplePlugin(CMSPluginBase):
name = "AllowedExamplePlugin"
render_template = "cms/content.html"
allowed_models = ["placeholderapp.example1"]
class DisallowedExamplePlugin(CMSPluginBase):
name = "DisallowedExamplePlugin"
render_template = "cms/content.html"
allowed_models = ["placeholderapp.someothermodel"]
self.AllowedExamplePlugin = AllowedExamplePlugin
self.DisallowedExamplePlugin = DisallowedExamplePlugin
plugin_pool.register_plugin(AllowedExamplePlugin)
plugin_pool.register_plugin(DisallowedExamplePlugin)
def tearDown(self):
# Unregister temporary plugins to avoid leaking into other tests
from cms.plugin_pool import plugin_pool
plugin_pool.unregister_plugin(self.AllowedExamplePlugin)
plugin_pool.unregister_plugin(self.DisallowedExamplePlugin)
def test_get_placeholder_toolbar_js_allowed_models_filtering(self):
from cms.plugin_rendering import ContentRenderer
request = self.get_request("/", "en")
renderer = ContentRenderer(request)
js = renderer.get_placeholder_toolbar_js(self.placeholder)
# Allowed plugin should appear
self.assertIn('"AllowedExamplePlugin"', js)
# Disallowed plugin must not appear
self.assertNotIn('"DisallowedExamplePlugin"', js)
# Sanity check: placeholder id present
self.assertIn(f'"placeholder_id": "{self.placeholder.pk}"', js)
def test_get_placeholder_toolbar_js_pagecontent_allowed_models(self):
from cms.api import create_page
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.plugin_rendering import ContentRenderer
# Create a CMS page placeholder as source model cms.pagecontent
cms_page = create_page("page", 'nav_playground.html', "en")
page_placeholder = cms_page.get_placeholders("en").get(slot='body')
class PageOnlyTmpPlugin(CMSPluginBase):
name = "PageOnlyTmpPlugin"
render_template = "cms/content.html"
allowed_models = ["cms.pagecontent"]
try:
plugin_pool.register_plugin(PageOnlyTmpPlugin)
# For a page placeholder, the page-only plugin should be present
request = self.get_request(cms_page.get_absolute_url('en'), 'en', page=cms_page)
renderer = ContentRenderer(request)
js_page = renderer.get_placeholder_toolbar_js(page_placeholder)
self.assertIn('"PageOnlyTmpPlugin"', js_page)
# For a non-page model placeholder, it must be excluded
request2 = self.get_request('/', 'en')
renderer2 = ContentRenderer(request2)
js_obj = renderer2.get_placeholder_toolbar_js(self.placeholder)
self.assertNotIn('"PageOnlyTmpPlugin"', js_obj)
finally:
plugin_pool.unregister_plugin(PageOnlyTmpPlugin)
class TestAllowedPluginsFiltering(CMSTestCase):
"""Tests for model-level plugin filtering via the allowed_plugins attribute.
Tests that get_placeholder_toolbar_js and get_all_plugins_for_model respect the
allowed_plugins property on models. This is a model-level restriction that works
in combination with the plugin-level allowed_models attribute.
Model-level filtering (allowed_plugins on model):
- None (default): All plugins are allowed (subject to plugin's allowed_models)
- List of plugin names: Only those plugins are allowed
- Empty list []: No plugins are allowed
We create a placeholder on a model with an allowed_plugins property and verify that only
those plugins appear in the toolbar and are returned by get_all_plugins_for_model.
"""
def setUp(self):
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
# Create an Example1 instance
self.example = Example1.objects.create(
char_1="ex1", char_2="ex2", char_3="ex3", char_4="ex4", publish=True
)
self.placeholder = self.example.placeholder
# Register temporary test plugins
class AllowedPluginA(CMSPluginBase):
name = "AllowedPluginA"
render_template = "cms/content.html"
class AllowedPluginB(CMSPluginBase):
name = "AllowedPluginB"
render_template = "cms/content.html"
class DisallowedPlugin(CMSPluginBase):
name = "DisallowedPlugin"
render_template = "cms/content.html"
self.AllowedPluginA = AllowedPluginA
self.AllowedPluginB = AllowedPluginB
self.DisallowedPlugin = DisallowedPlugin
plugin_pool.register_plugin(AllowedPluginA)
plugin_pool.register_plugin(AllowedPluginB)
plugin_pool.register_plugin(DisallowedPlugin)
def tearDown(self):
from cms.plugin_pool import plugin_pool
plugin_pool.unregister_plugin(self.AllowedPluginA)
plugin_pool.unregister_plugin(self.AllowedPluginB)
plugin_pool.unregister_plugin(self.DisallowedPlugin)
def test_get_all_plugins_for_model_without_allowed_plugins(self):
"""Test that models without allowed_plugins (None) return all plugins.
When allowed_plugins is None (not defined), no model-level filtering occurs.
Only plugin-level filtering (allowed_models) is applied.
"""
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
all_plugins = plugin_pool.get_all_plugins_for_model(Example1)
plugin_names = [p.__name__ for p in all_plugins]
# All three test plugins should be included
self.assertIn('AllowedPluginA', plugin_names)
self.assertIn('AllowedPluginB', plugin_names)
self.assertIn('DisallowedPlugin', plugin_names)
def test_get_all_plugins_for_model_with_allowed_plugins(self):
"""Test that allowed_plugins property applies model-level filtering.
When allowed_plugins is a list of plugin names, only those plugins
(and those passing the allowed_models filter) are returned.
"""
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
# Temporarily add allowed_plugins to Example1
Example1.allowed_plugins = ['AllowedPluginA', 'AllowedPluginB']
# Clear the cache since get_all_plugins_for_model uses @lru_cache
plugin_pool.get_all_plugins_for_model.cache_clear()
try:
filtered_plugins = plugin_pool.get_all_plugins_for_model(Example1)
plugin_names = [p.__name__ for p in filtered_plugins]
# Only allowed plugins should be included
self.assertIn('AllowedPluginA', plugin_names)
self.assertIn('AllowedPluginB', plugin_names)
self.assertNotIn('DisallowedPlugin', plugin_names)
finally:
# Clean up
delattr(Example1, 'allowed_plugins')
plugin_pool.get_all_plugins_for_model.cache_clear()
def test_get_all_plugins_for_model_with_empty_allowed_plugins(self):
"""Test that empty allowed_plugins list returns no plugins.
Semantic difference:
- None: No model-level restriction (all plugins allowed subject to allowed_models)
- []: Explicit restriction to allow no plugins
- ['PluginA']: Explicit restriction to specific plugins
"""
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
Example1.allowed_plugins = []
plugin_pool.get_all_plugins_for_model.cache_clear()
try:
filtered_plugins = plugin_pool.get_all_plugins_for_model(Example1)
# Empty list should result in no plugins
self.assertEqual(len(filtered_plugins), 0)
finally:
delattr(Example1, 'allowed_plugins')
plugin_pool.get_all_plugins_for_model.cache_clear()
def test_get_placeholder_toolbar_js_with_allowed_plugins(self):
"""Test that toolbar JS respects model-level allowed_plugins filtering.
The toolbar should only show plugins that pass both the plugin's allowed_models
filter and the model's allowed_plugins filter.
"""
from cms.plugin_rendering import ContentRenderer
from cms.test_utils.project.placeholderapp.models import Example1
Example1.allowed_plugins = ['AllowedPluginA', 'AllowedPluginB']
try:
request = self.get_request("/", "en")
renderer = ContentRenderer(request)
js = renderer.get_placeholder_toolbar_js(self.placeholder)
# Allowed plugins should appear
self.assertIn('"AllowedPluginA"', js)
self.assertIn('"AllowedPluginB"', js)
# Disallowed plugin must not appear
self.assertNotIn('"DisallowedPlugin"', js)
finally:
delattr(Example1, 'allowed_plugins')
from cms.plugin_pool import plugin_pool
plugin_pool.get_all_plugins_for_model.cache_clear()
def test_allowed_plugins_with_nonexistent_plugin(self):
"""Test that nonexistent plugins in allowed_plugins are safely ignored.
If allowed_plugins contains plugin names that don't exist or aren't registered,
they are silently ignored without raising errors.
"""
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
Example1.allowed_plugins = ['AllowedPluginA', 'NonExistentPlugin']
plugin_pool.get_all_plugins_for_model.cache_clear()
try:
filtered_plugins = plugin_pool.get_all_plugins_for_model(Example1)
plugin_names = [p.__name__ for p in filtered_plugins]
# Only the existing allowed plugin should be included
self.assertIn('AllowedPluginA', plugin_names)
self.assertNotIn('AllowedPluginB', plugin_names)
self.assertNotIn('DisallowedPlugin', plugin_names)
# No error should be raised for nonexistent plugin
finally:
delattr(Example1, 'allowed_plugins')
plugin_pool.get_all_plugins_for_model.cache_clear()
def test_allowed_plugins_combined_with_allowed_models(self):
"""Test that both allowed_plugins and allowed_models filters work together.
For a plugin to be available on a model, it must pass both filters:
1. Plugin-level: If plugin.allowed_models is set, model must be in the list
2. Model-level: If model.allowed_plugins is set, plugin must be in the list
If either filter excludes the plugin, it won't be available.
"""
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.test_utils.project.placeholderapp.models import Example1
# Register a plugin with allowed_models restriction
class RestrictedModelPlugin(CMSPluginBase):
name = "RestrictedModelPlugin"
render_template = "cms/content.html"
allowed_models = ["placeholderapp.example1"]
class WrongModelPlugin(CMSPluginBase):
name = "WrongModelPlugin"
render_template = "cms/content.html"
allowed_models = ["placeholderapp.someothermodel"]
plugin_pool.register_plugin(RestrictedModelPlugin)
plugin_pool.register_plugin(WrongModelPlugin)
Example1.allowed_plugins = ['AllowedPluginA', 'RestrictedModelPlugin', 'WrongModelPlugin']
plugin_pool.get_all_plugins_for_model.cache_clear()
try:
filtered_plugins = plugin_pool.get_all_plugins_for_model(Example1)
plugin_names = [p.__name__ for p in filtered_plugins]
# AllowedPluginA should be included (in allowed_plugins, no model restriction)
self.assertIn('AllowedPluginA', plugin_names)
# RestrictedModelPlugin should be included (in allowed_plugins AND correct allowed_models)
self.assertIn('RestrictedModelPlugin', plugin_names)
# WrongModelPlugin should be excluded (in allowed_plugins but wrong allowed_models)
self.assertNotIn('WrongModelPlugin', plugin_names)
# AllowedPluginB should be excluded (not in allowed_plugins)
self.assertNotIn('AllowedPluginB', plugin_names)
finally:
delattr(Example1, 'allowed_plugins')
plugin_pool.unregister_plugin(RestrictedModelPlugin)
plugin_pool.unregister_plugin(WrongModelPlugin)
plugin_pool.get_all_plugins_for_model.cache_clear()