Ver Fonte

Closes #19971: Expand test coverage for config & export templates (#22164)

Jeremy Stretch há 1 semana atrás
pai
commit
c27c470499

+ 2 - 1
netbox/extras/models/mixins.py

@@ -138,7 +138,8 @@ class RenderTemplateMixin(models.Model):
         """
         """
         Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
         Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
         """
         """
-        params = self.environment_params or {}
+        # Shallow-copy so resolved imports don't replace the string values on the model field itself.
+        params = dict(self.environment_params or {})
         for name, value in params.items():
         for name, value in params.items():
             if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
             if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
                 params[name] = import_string(value)
                 params[name] = import_string(value)

+ 181 - 6
netbox/extras/tests/test_models.py

@@ -1,6 +1,7 @@
 import io
 import io
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
+from types import SimpleNamespace
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -9,11 +10,13 @@ from django.core.files.storage import Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import ValidationError
 from django.forms import ValidationError
 from django.test import TestCase, tag
 from django.test import TestCase, tag
+from jinja2 import StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
 from PIL import Image
 from PIL import Image
 
 
 from core.events import OBJECT_CREATED
 from core.events import OBJECT_CREATED
 from core.models import AutoSyncRecord, DataSource, ObjectType
 from core.models import AutoSyncRecord, DataSource, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
+from extras.constants import DEFAULT_MIME_TYPE
 from extras.models import (
 from extras.models import (
     ConfigContext,
     ConfigContext,
     ConfigContextProfile,
     ConfigContextProfile,
@@ -26,6 +29,7 @@ from extras.models import (
 )
 )
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from utilities.jinja2 import render_jinja2
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
@@ -925,30 +929,24 @@ class ConfigTemplateDebugTestCase(TestCase):
 
 
     def test_template_error_non_debug_no_traceback(self):
     def test_template_error_non_debug_no_traceback(self):
         """In non-debug mode, a TemplateError raises with no traceback exposure."""
         """In non-debug mode, a TemplateError raises with no traceback exposure."""
-        from jinja2 import TemplateError
         t = self._make_template("{{ unclosed", debug=False)
         t = self._make_template("{{ unclosed", debug=False)
         with self.assertRaises(TemplateError):
         with self.assertRaises(TemplateError):
             t.render({})
             t.render({})
 
 
     def test_template_error_debug_mode_raises(self):
     def test_template_error_debug_mode_raises(self):
         """In debug mode, a TemplateError still raises (callers handle display)."""
         """In debug mode, a TemplateError still raises (callers handle display)."""
-        from jinja2 import TemplateError
         t = self._make_template("{{ unclosed", debug=True)
         t = self._make_template("{{ unclosed", debug=True)
         with self.assertRaises(TemplateError):
         with self.assertRaises(TemplateError):
             t.render({})
             t.render({})
 
 
     def test_render_jinja2_debug_extension_enabled(self):
     def test_render_jinja2_debug_extension_enabled(self):
         """When debug=True, the Jinja2 debug extension is loaded in the environment."""
         """When debug=True, the Jinja2 debug extension is loaded in the environment."""
-        from utilities.jinja2 import render_jinja2
         # The {% debug %} tag is only available when the debug extension is loaded.
         # The {% debug %} tag is only available when the debug extension is loaded.
         output = render_jinja2("{% debug %}", {}, debug=True)
         output = render_jinja2("{% debug %}", {}, debug=True)
         self.assertIsInstance(output, str)
         self.assertIsInstance(output, str)
 
 
     def test_render_jinja2_debug_extension_not_loaded_by_default(self):
     def test_render_jinja2_debug_extension_not_loaded_by_default(self):
         """When debug=False, the {% debug %} tag is not available."""
         """When debug=False, the {% debug %} tag is not available."""
-        from jinja2 import TemplateSyntaxError
-
-        from utilities.jinja2 import render_jinja2
         with self.assertRaises(TemplateSyntaxError):
         with self.assertRaises(TemplateSyntaxError):
             render_jinja2("{% debug %}", {}, debug=False)
             render_jinja2("{% debug %}", {}, debug=False)
 
 
@@ -986,6 +984,183 @@ class ExportTemplateContextTestCase(TestCase):
         self.assertIs(ctx['dcim']['Site'], Site)
         self.assertIs(ctx['dcim']['Site'], Site)
 
 
 
 
+def finalize_none_to_dash(value):
+    """
+    Module-level helper used by RenderTemplateMixinRenderTest.test_environment_params_finalize_path_import.
+    Exported so it can be referenced by dotted path from a Jinja environment_params value.
+    """
+    return '-' if value is None else value
+
+
+class RenderTemplateMixinRenderTest(TestCase):
+    """
+    Tests for RenderTemplateMixin.render() and get_environment_params(), exercised via ConfigTemplate.
+    """
+
+    def test_render_basic_context(self):
+        t = ConfigTemplate(name='basic', template_code='Hello {{ name }}')
+        self.assertEqual(t.render({'name': 'world'}), 'Hello world')
+
+    def test_render_normalizes_crlf(self):
+        t = ConfigTemplate(name='crlf', template_code='line1\r\nline2\r\nline3')
+        self.assertEqual(t.render({}), 'line1\nline2\nline3')
+
+    def test_render_passes_environment_params(self):
+        # With trim_blocks + lstrip_blocks, block tags don't emit their surrounding whitespace.
+        template_code = '{% if x %}\n    {% if y %}\n        VALUE\n    {% endif %}\n{% endif %}'
+        plain = ConfigTemplate(name='plain', template_code=template_code)
+        trimmed = ConfigTemplate(
+            name='trimmed',
+            template_code=template_code,
+            environment_params={'trim_blocks': True, 'lstrip_blocks': True},
+        )
+        ctx = {'x': True, 'y': True}
+        self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
+        self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
+
+    def test_environment_params_undefined_path_import(self):
+        # Default Undefined renders nothing for a missing variable.
+        default = ConfigTemplate(name='default', template_code='{{ missing }}')
+        self.assertEqual(default.render({}), '')
+
+        # StrictUndefined (resolved from its dotted path) raises on access.
+        strict = ConfigTemplate(
+            name='strict',
+            template_code='{{ missing }}',
+            environment_params={'undefined': 'jinja2.StrictUndefined'},
+        )
+        with self.assertRaises(UndefinedError):
+            strict.render({})
+
+    def test_environment_params_finalize_path_import(self):
+        t = ConfigTemplate(
+            name='finalize',
+            template_code='{{ v }}',
+            environment_params={'finalize': 'extras.tests.test_models.finalize_none_to_dash'},
+        )
+        self.assertEqual(t.render({'v': None}), '-')
+        self.assertEqual(t.render({'v': 'abc'}), 'abc')
+
+    def test_get_environment_params_handles_none(self):
+        # The environment_params field may be cleared; ensure the mixin returns a dict (not None).
+        t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
+        self.assertEqual(t.get_environment_params(), {})
+
+    def test_get_environment_params_resolves_path_imports(self):
+        t = ConfigTemplate(
+            name='resolve',
+            template_code='ok',
+            environment_params={'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True},
+        )
+        params = t.get_environment_params()
+        self.assertIs(params['undefined'], StrictUndefined)
+        self.assertIs(params['trim_blocks'], True)
+
+    def test_get_environment_params_does_not_mutate_field(self):
+        # Resolving path imports must not replace the string values stored on the model field.
+        t = ConfigTemplate(
+            name='no-mutate',
+            template_code='ok',
+            environment_params={'undefined': 'jinja2.StrictUndefined'},
+        )
+        t.get_environment_params()
+        t.get_environment_params()
+        self.assertEqual(t.environment_params, {'undefined': 'jinja2.StrictUndefined'})
+
+
+class RenderTemplateMixinResponseTest(TestCase):
+    """
+    Tests for RenderTemplateMixin.render_to_response() HTTP behavior.
+    """
+
+    def test_response_default_mime_type(self):
+        t = ConfigTemplate(name='t', template_code='ok')
+        response = t.render_to_response({})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
+
+    def test_response_custom_mime_type(self):
+        t = ConfigTemplate(name='t', template_code='{}', mime_type='application/json')
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Type'], 'application/json')
+
+    def test_response_attachment_with_file_name(self):
+        t = ConfigTemplate(
+            name='t', template_code='ok', file_name='router1', file_extension='cfg', as_attachment=True,
+        )
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1.cfg"')
+
+    def test_response_attachment_filename_from_queryset(self):
+        Site.objects.create(name='Site 1', slug='site-1')
+        t = ExportTemplate(
+            name='t',
+            template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
+            file_extension='txt',
+            as_attachment=True,
+        )
+        response = t.render_to_response(queryset=Site.objects.all())
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+
+    def test_response_attachment_filename_from_device_context(self):
+        t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
+        device = SimpleNamespace(name='router1')
+        response = t.render_to_response(context={'device': device})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1"')
+
+    def test_response_attachment_fallback_filename(self):
+        # No file_name, no queryset, no device/vm key in context: filename falls back to "output".
+        t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="output"')
+
+    def test_response_as_attachment_false_omits_disposition(self):
+        t = ConfigTemplate(name='t', template_code='ok', file_name='router1', as_attachment=False)
+        response = t.render_to_response({})
+        self.assertNotIn('Content-Disposition', response)
+
+    def test_response_body_matches_render(self):
+        t = ConfigTemplate(name='t', template_code='Hello {{ name }}')
+        rendered = t.render({'name': 'world'})
+        response = t.render_to_response({'name': 'world'})
+        self.assertEqual(response.content.decode(), rendered)
+
+
+class ExportTemplateRenderTest(TestCase):
+    """
+    Tests for ExportTemplate.render() with a queryset bound into the template context.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.bulk_create([
+            Site(name='Site A', slug='site-a'),
+            Site(name='Site B', slug='site-b'),
+            Site(name='Site C', slug='site-c'),
+        ])
+
+    def test_render_iterates_queryset(self):
+        t = ExportTemplate(
+            name='sites',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+        )
+        queryset = Site.objects.order_by('name')
+        output = t.render(queryset=queryset)
+        self.assertEqual(output, 'Site A\nSite B\nSite C\n')
+
+    def test_render_to_response_for_queryset(self):
+        t = ExportTemplate(
+            name='sites',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            file_extension='txt',
+        )
+        response = t.render_to_response(queryset=Site.objects.order_by('name'))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+        self.assertEqual(response.content.decode(), 'Site A\nSite B\nSite C\n')
+
+
 class EventRuleTestCase(TestCase):
 class EventRuleTestCase(TestCase):
 
 
     def test_action_data_clean_accepts_dict(self):
     def test_action_data_clean_accepts_dict(self):

+ 53 - 0
netbox/extras/tests/test_views.py

@@ -370,6 +370,59 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
+class ExportTemplateExportFlowTest(TestCase):
+    """
+    End-to-end test for ExportTemplate invocation via a list view's ?export=<name> query param.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.bulk_create([
+            Site(name='Site A', slug='site-a'),
+            Site(name='Site B', slug='site-b'),
+        ])
+
+        site_type = ObjectType.objects.get_for_model(Site)
+
+        ok_template = ExportTemplate.objects.create(
+            name='Sites Export',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            mime_type='text/plain',
+            file_extension='txt',
+        )
+        ok_template.object_types.set([site_type])
+
+        broken_template = ExportTemplate.objects.create(
+            name='Broken Export',
+            template_code='{% for obj in queryset %}{{ obj.name ',  # unterminated expression
+        )
+        broken_template.object_types.set([site_type])
+
+    def test_export_template_invocation(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('dcim:site_list')
+
+        response = self.client.get(f'{url}?export=Sites Export')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], 'text/plain')
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+        # The rendered queryset reflects whatever ordering the list view applies. Assert on set
+        # membership rather than line order so the test isn't coupled to Site's natural ordering.
+        rendered_names = set(filter(None, response.content.decode().split('\n')))
+        self.assertEqual(rendered_names, {'Site A', 'Site B'})
+
+    def test_export_template_render_error_redirects(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('dcim:site_list')
+
+        # A broken template surfaces an exception during render; the view catches it and redirects
+        # back to the (filtered) list view rather than returning a 500.
+        response = self.client.get(f'{url}?export=Broken Export')
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['Location'].startswith(url))
+        self.assertNotIn('export=', response['Location'])
+
+
 class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Webhook
     model = Webhook