From c27c47049936f537576b290dc75502147c2dd0d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 11 May 2026 17:29:51 -0400 Subject: [PATCH] Closes #19971: Expand test coverage for config & export templates (#22164) --- netbox/extras/models/mixins.py | 3 +- netbox/extras/tests/test_models.py | 187 ++++++++++++++++++++++++++++- netbox/extras/tests/test_views.py | 53 ++++++++ 3 files changed, 236 insertions(+), 7 deletions(-) diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 805916d9f..79df67a11 100644 --- a/netbox/extras/models/mixins.py +++ b/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). """ - 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(): if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str: params[name] = import_string(value) diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 938e1ae35..d3a2ad6fd 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,7 @@ import io import tempfile from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch 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.forms import ValidationError from django.test import TestCase, tag +from jinja2 import StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError from PIL import Image from core.events import OBJECT_CREATED from core.models import AutoSyncRecord, DataSource, ObjectType from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup +from extras.constants import DEFAULT_MIME_TYPE from extras.models import ( ConfigContext, ConfigContextProfile, @@ -26,6 +29,7 @@ from extras.models import ( ) from tenancy.models import Tenant, TenantGroup from utilities.exceptions import AbortRequest +from utilities.jinja2 import render_jinja2 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -925,30 +929,24 @@ class ConfigTemplateDebugTestCase(TestCase): def test_template_error_non_debug_no_traceback(self): """In non-debug mode, a TemplateError raises with no traceback exposure.""" - from jinja2 import TemplateError t = self._make_template("{{ unclosed", debug=False) with self.assertRaises(TemplateError): t.render({}) def test_template_error_debug_mode_raises(self): """In debug mode, a TemplateError still raises (callers handle display).""" - from jinja2 import TemplateError t = self._make_template("{{ unclosed", debug=True) with self.assertRaises(TemplateError): t.render({}) def test_render_jinja2_debug_extension_enabled(self): """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. output = render_jinja2("{% debug %}", {}, debug=True) self.assertIsInstance(output, str) def test_render_jinja2_debug_extension_not_loaded_by_default(self): """When debug=False, the {% debug %} tag is not available.""" - from jinja2 import TemplateSyntaxError - - from utilities.jinja2 import render_jinja2 with self.assertRaises(TemplateSyntaxError): render_jinja2("{% debug %}", {}, debug=False) @@ -986,6 +984,183 @@ class ExportTemplateContextTestCase(TestCase): 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): def test_action_data_clean_accepts_dict(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 42ea6ce16..3d5bd48d1 100644 --- a/netbox/extras/tests/test_views.py +++ b/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= 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): model = Webhook