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

This commit is contained in:
Jeremy Stretch 2026-05-11 17:29:51 -04:00 committed by GitHub
parent 963306f338
commit c27c470499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 236 additions and 7 deletions

View File

@ -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)

View File

@ -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):

View File

@ -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):
model = Webhook