test_models.py 81 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094
  1. import io
  2. import os
  3. import sys
  4. import tempfile
  5. from pathlib import Path
  6. from types import SimpleNamespace
  7. from unittest.mock import patch
  8. from django.conf import settings
  9. from django.contrib.contenttypes.models import ContentType
  10. from django.core.files.base import ContentFile
  11. from django.core.files.storage import Storage
  12. from django.core.files.uploadedfile import SimpleUploadedFile
  13. from django.db import connection
  14. from django.db.models import FETCH_PEERS
  15. from django.forms import ValidationError
  16. from django.test import TestCase, override_settings, tag
  17. from django.test.utils import CaptureQueriesContext
  18. from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
  19. from PIL import Image
  20. from rq.queue import Queue
  21. from core.events import OBJECT_CREATED
  22. from core.models import AutoSyncRecord, DataSource, ObjectType
  23. from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
  24. from extras.constants import DEFAULT_MIME_TYPE
  25. from extras.models import (
  26. ConfigContext,
  27. ConfigContextProfile,
  28. ConfigTemplate,
  29. EventRule,
  30. ExportTemplate,
  31. ImageAttachment,
  32. TableConfig,
  33. Tag,
  34. TaggedItem,
  35. Webhook,
  36. )
  37. from extras.models.mixins import RenderTemplateMixin
  38. from tenancy.models import Tenant, TenantGroup
  39. from utilities.exceptions import AbortRequest
  40. from utilities.jinja2 import env_filter, render_jinja2, sanitize_http_header
  41. from utilities.tables import get_table_for_model
  42. from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
  43. class OverwriteStyleMemoryStorage(Storage):
  44. """
  45. In-memory storage that mimics overwrite-style backends by returning the
  46. incoming name unchanged from get_available_name().
  47. """
  48. def __init__(self):
  49. self.files = {}
  50. def _open(self, name, mode='rb'):
  51. return ContentFile(self.files[name], name=name)
  52. def _save(self, name, content):
  53. self.files[name] = content.read()
  54. return name
  55. def delete(self, name):
  56. self.files.pop(name, None)
  57. def exists(self, name):
  58. return name in self.files
  59. def get_available_name(self, name, max_length=None):
  60. return name
  61. def get_alternative_name(self, file_root, file_ext):
  62. return f'{file_root}_sdmmer4{file_ext}'
  63. def listdir(self, path):
  64. return [], list(self.files)
  65. def size(self, name):
  66. return len(self.files[name])
  67. def url(self, name):
  68. return f'https://example.invalid/{name}'
  69. class UnreadableSizeMemoryStorage(OverwriteStyleMemoryStorage):
  70. """
  71. Like OverwriteStyleMemoryStorage, but size() raises OSError to model a storage backend that is
  72. transiently unavailable (e.g. an S3 outage) when reading file size.
  73. """
  74. def size(self, name):
  75. raise OSError('storage unavailable')
  76. class ImageAttachmentTestCase(TestCase):
  77. @classmethod
  78. def setUpTestData(cls):
  79. cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
  80. cls.ct_site = ContentType.objects.get_by_natural_key('dcim', 'site')
  81. cls.site = Site.objects.create(name='Site 1')
  82. cls.image_content = b''
  83. def _stub_image_attachment(self, object_id, image_filename, name=None):
  84. """
  85. Creates an instance of ImageAttachment with the provided object_id and image_name.
  86. This method prepares a stubbed image attachment to test functionalities that
  87. require an ImageAttachment object.
  88. The function initializes the attachment with a specified file name and
  89. pre-defined image content.
  90. """
  91. ia = ImageAttachment(
  92. object_type=self.ct_rack,
  93. object_id=object_id,
  94. name=name,
  95. image=SimpleUploadedFile(
  96. name=image_filename,
  97. content=self.image_content,
  98. content_type='image/jpeg',
  99. ),
  100. )
  101. return ia
  102. def _uploaded_png(self, filename):
  103. image = io.BytesIO()
  104. Image.new('RGB', (1, 1)).save(image, format='PNG')
  105. return SimpleUploadedFile(
  106. name=filename,
  107. content=image.getvalue(),
  108. content_type='image/png',
  109. )
  110. def test_filename_strips_expected_prefix(self):
  111. """
  112. Tests that the filename of the image attachment is stripped of the expected
  113. prefix.
  114. """
  115. ia = self._stub_image_attachment(12, 'image-attachments/rack_12_My_File.png')
  116. self.assertEqual(ia.filename, 'My_File.png')
  117. def test_filename_legacy_nested_path_returns_basename(self):
  118. """
  119. Tests if the filename of a legacy-nested path correctly returns only the basename.
  120. """
  121. # e.g. "image-attachments/rack_12_5/31/23.jpg" -> "23.jpg"
  122. ia = self._stub_image_attachment(12, 'image-attachments/rack_12_5/31/23.jpg')
  123. self.assertEqual(ia.filename, '23.jpg')
  124. def test_filename_no_prefix_returns_basename(self):
  125. """
  126. Tests that the filename property correctly returns the basename for an image
  127. attachment that has no leading prefix in its path.
  128. """
  129. ia = self._stub_image_attachment(42, 'image-attachments/just_name.webp')
  130. self.assertEqual(ia.filename, 'just_name.webp')
  131. def test_mismatched_prefix_is_not_stripped(self):
  132. """
  133. Tests that a mismatched prefix in the filename is not stripped.
  134. """
  135. # Prefix does not match object_id -> leave as-is (basename only)
  136. ia = self._stub_image_attachment(12, 'image-attachments/rack_13_other.png')
  137. self.assertEqual('rack_13_other.png', ia.filename)
  138. def test_str_uses_name_when_present(self):
  139. """
  140. Tests that the `str` representation of the object uses the
  141. `name` attribute when provided.
  142. """
  143. ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='Human title')
  144. self.assertEqual('Human title', str(ia))
  145. def test_str_falls_back_to_filename(self):
  146. """
  147. Tests that the `str` representation of the object falls back to
  148. the filename if the name attribute is not set.
  149. """
  150. ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
  151. self.assertEqual('file.png', str(ia))
  152. def test_duplicate_uploaded_names_get_suffixed_with_overwrite_style_storage(self):
  153. storage = OverwriteStyleMemoryStorage()
  154. field = ImageAttachment._meta.get_field('image')
  155. with patch.object(field, 'storage', storage):
  156. first = ImageAttachment(
  157. object_type=self.ct_site,
  158. object_id=self.site.pk,
  159. image=self._uploaded_png('action-buttons.png'),
  160. )
  161. first.save()
  162. second = ImageAttachment(
  163. object_type=self.ct_site,
  164. object_id=self.site.pk,
  165. image=self._uploaded_png('action-buttons.png'),
  166. )
  167. second.save()
  168. base_name = f'image-attachments/site_{self.site.pk}_action-buttons.png'
  169. suffixed_name = f'image-attachments/site_{self.site.pk}_action-buttons_sdmmer4.png'
  170. self.assertEqual(first.image.name, base_name)
  171. self.assertEqual(second.image.name, suffixed_name)
  172. self.assertNotEqual(first.image.name, second.image.name)
  173. self.assertEqual(first.filename, 'action-buttons.png')
  174. self.assertEqual(second.filename, 'action-buttons_sdmmer4.png')
  175. self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
  176. def test_save_populates_image_size_on_create(self):
  177. """
  178. save() populates image_size from the uploaded file on creation.
  179. """
  180. storage = OverwriteStyleMemoryStorage()
  181. field = ImageAttachment._meta.get_field('image')
  182. with patch.object(field, 'storage', storage):
  183. ia = ImageAttachment(
  184. object_type=self.ct_site,
  185. object_id=self.site.pk,
  186. image=self._uploaded_png('size-on-create.png'),
  187. )
  188. ia.save()
  189. self.assertIsNotNone(ia.image_size)
  190. self.assertEqual(ia.image_size, ia.image.size)
  191. def test_size_property_returns_stored_value_without_storage_access(self):
  192. """
  193. The size property returns the cached image_size rather than the file's actual size. The stub's empty
  194. file reports size 0, so asserting the distinct stored value proves the property used the cached value.
  195. """
  196. ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_no-file.png')
  197. self.assertEqual(ia._read_image_size(), 0) # the stub's empty file genuinely reports 0
  198. ia.image_size = 9999
  199. self.assertEqual(ia.size, 9999)
  200. def test_size_property_falls_back_to_storage_when_unset(self):
  201. """
  202. For legacy rows where image_size is NULL, the size property falls back to reading storage
  203. (rather than reporting 0 bytes).
  204. """
  205. storage = OverwriteStyleMemoryStorage()
  206. field = ImageAttachment._meta.get_field('image')
  207. with patch.object(field, 'storage', storage):
  208. ia = ImageAttachment(
  209. object_type=self.ct_site,
  210. object_id=self.site.pk,
  211. image=self._uploaded_png('fallback.png'),
  212. )
  213. ia.save()
  214. # Simulate a legacy row that predates the image_size field.
  215. ia.image_size = None
  216. self.assertEqual(ia.size, ia.image.size)
  217. self.assertGreater(ia.size, 0)
  218. def test_save_does_not_clobber_existing_size_on_storage_error(self):
  219. """
  220. When the storage backend raises on a size read (modeled by a real Storage subclass, not a mock),
  221. save() must not overwrite an existing image_size with None.
  222. """
  223. field = ImageAttachment._meta.get_field('image')
  224. # Create a row with a real, readable size.
  225. with patch.object(field, 'storage', OverwriteStyleMemoryStorage()):
  226. ia = ImageAttachment(
  227. object_type=self.ct_site,
  228. object_id=self.site.pk,
  229. image=self._uploaded_png('keep-size.png'),
  230. )
  231. ia.save()
  232. original_size = ia.image_size
  233. self.assertIsNotNone(original_size)
  234. # Reload from the DB so the FieldFile has no cached size and must consult storage (as it would for a
  235. # row loaded fresh in production). With the backend unable to report size, the read fails (returns None),
  236. # and save() must keep the previously-stored value rather than clobbering it with None.
  237. with patch.object(field, 'storage', UnreadableSizeMemoryStorage()):
  238. reloaded = ImageAttachment.objects.get(pk=ia.pk)
  239. self.assertIsNone(reloaded._read_image_size()) # the read genuinely fails (returns None)
  240. # Make the image look replaced by perturbing the cached identity (different name component).
  241. reloaded._orig_image_key = ('image-attachments/site_1_old.png', reloaded.image_height, reloaded.image_width)
  242. reloaded.save()
  243. # In-memory value is preserved, and the persisted value is unchanged.
  244. self.assertEqual(reloaded.image_size, original_size)
  245. self.assertEqual(ImageAttachment.objects.get(pk=ia.pk).image_size, original_size)
  246. def test_save_recomputes_image_size_when_image_replaced(self):
  247. """
  248. Replacing the image on an existing row recomputes image_size (Cable-style change detection).
  249. """
  250. storage = OverwriteStyleMemoryStorage()
  251. field = ImageAttachment._meta.get_field('image')
  252. with patch.object(field, 'storage', storage):
  253. ia = ImageAttachment(
  254. object_type=self.ct_site,
  255. object_id=self.site.pk,
  256. image=self._uploaded_png('original.png'),
  257. )
  258. ia.save()
  259. original_size = ia.image_size
  260. self.assertIsNotNone(original_size)
  261. # Replace the image with a larger file and save again.
  262. larger = SimpleUploadedFile(
  263. name='replacement.png',
  264. content=self._uploaded_png('replacement.png').read() + b'\x00' * 100,
  265. content_type='image/png',
  266. )
  267. ia.image = larger
  268. ia.save()
  269. self.assertEqual(ia.image_size, ia.image.size)
  270. self.assertNotEqual(ia.image_size, original_size)
  271. def test_image_identity_includes_dimensions(self):
  272. """
  273. The change-detection key combines the image name with its dimensions, so a replacement that reuses the
  274. same name but changes dimensions produces a different key (which name alone would not).
  275. """
  276. ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_same.png')
  277. ia.image_height, ia.image_width = 10, 10
  278. key_small = ia._image_identity()
  279. # Same name, different dimensions (as Django would set when a same-named file is replaced).
  280. ia.image_height, ia.image_width = 40, 40
  281. key_large = ia._image_identity()
  282. self.assertEqual(key_small[0], key_large[0]) # name component unchanged
  283. self.assertNotEqual(key_small, key_large) # but the key differs, so save() will recompute
  284. def test_save_recomputes_image_size_when_dimensions_change_under_same_name(self):
  285. """
  286. When the image is replaced by a file with the same stored name but different dimensions, save()
  287. recomputes image_size. Name-only detection would miss this; the dimension component catches it.
  288. Simulates the same-name case by priming the cached identity with the old dimensions.
  289. """
  290. storage = OverwriteStyleMemoryStorage()
  291. field = ImageAttachment._meta.get_field('image')
  292. with patch.object(field, 'storage', storage):
  293. ia = ImageAttachment(
  294. object_type=self.ct_site,
  295. object_id=self.site.pk,
  296. image=self._uploaded_png('same-name.png'),
  297. )
  298. ia.save()
  299. name = ia.image.name
  300. # Force the cached identity to reflect the SAME name but different (old) dimensions, then bump the
  301. # current dimensions to mimic a same-name replacement with a differently-sized image.
  302. ia._orig_image_key = (name, ia.image_height + 5, ia.image_width + 5)
  303. ia.save()
  304. self.assertEqual(ia.image.name, name) # name unchanged
  305. self.assertEqual(ia.image_size, ia.image.size) # size recomputed despite same name
  306. def test_save_without_touching_image_does_not_recompute_or_read_storage(self):
  307. """
  308. Editing an existing row without replacing the image leaves image_size untouched and does not
  309. hit storage. Directly guards against the change-detection comparison misfiring.
  310. """
  311. storage = OverwriteStyleMemoryStorage()
  312. field = ImageAttachment._meta.get_field('image')
  313. with patch.object(field, 'storage', storage):
  314. ia = ImageAttachment(
  315. object_type=self.ct_site,
  316. object_id=self.site.pk,
  317. name='Original',
  318. image=self._uploaded_png('untouched.png'),
  319. )
  320. ia.save()
  321. stored_size = ia.image_size
  322. # Reload from the DB so the cached image identity is set from the persisted value, then edit only the name.
  323. reloaded = ImageAttachment.objects.get(pk=ia.pk)
  324. reloaded.name = 'Renamed'
  325. with patch.object(ImageAttachment, '_read_image_size', side_effect=AssertionError('storage accessed')):
  326. reloaded.save()
  327. self.assertEqual(reloaded.image_size, stored_size)
  328. def test_save_populates_image_size_via_constructor_kwarg(self):
  329. """
  330. The non-UI create path (constructor kwarg / REST / bulk) populates image_size correctly,
  331. confirming change detection behaves when image is passed as a FieldFile.
  332. """
  333. storage = OverwriteStyleMemoryStorage()
  334. field = ImageAttachment._meta.get_field('image')
  335. with patch.object(field, 'storage', storage):
  336. ia = ImageAttachment(
  337. object_type=self.ct_site,
  338. object_id=self.site.pk,
  339. image=self._uploaded_png('kwarg.png'),
  340. )
  341. ia.save()
  342. self.assertIsNotNone(ia.image_size)
  343. self.assertEqual(ia.image_size, ia.image.size)
  344. class TableConfigTestCase(TestCase):
  345. @classmethod
  346. def setUpTestData(cls):
  347. cls.site_ct = ContentType.objects.get_for_model(Site)
  348. cls.table_name = get_table_for_model(Site).__name__
  349. def test_clean_accepts_ordering_none(self):
  350. """clean() must accept ordering=None (field is null=True)."""
  351. tc = TableConfig(
  352. object_type=self.site_ct,
  353. table=self.table_name,
  354. name='No ordering',
  355. columns=['name'],
  356. # ordering left unset (defaults to None)
  357. )
  358. # Must not raise TypeError: 'NoneType' object is not iterable
  359. tc.full_clean()
  360. def test_clean_without_object_type(self):
  361. """full_clean() on an instance missing its object type must raise ValidationError."""
  362. tc = TableConfig(
  363. table=self.table_name,
  364. name='No object type',
  365. columns=['name'],
  366. )
  367. with self.assertRaises(ValidationError):
  368. tc.full_clean()
  369. def test_clean_accepts_columns_none(self):
  370. """full_clean() must report missing columns rather than raise TypeError."""
  371. tc = TableConfig(
  372. object_type=self.site_ct,
  373. table=self.table_name,
  374. name='No columns',
  375. )
  376. with self.assertRaises(ValidationError):
  377. tc.full_clean()
  378. class TagTestCase(TestCase):
  379. def test_default_ordering_weight_then_name_is_set(self):
  380. Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000)
  381. Tag.objects.create(name='Tag 2', slug='tag-2') # Default: 1000
  382. Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000)
  383. Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000)
  384. tags = Tag.objects.all()
  385. self.assertEqual(tags[0].slug, 'tag-2')
  386. self.assertEqual(tags[1].slug, 'tag-3')
  387. self.assertEqual(tags[2].slug, 'tag-4')
  388. self.assertEqual(tags[3].slug, 'tag-1')
  389. def test_tag_related_manager_ordering_weight_then_name(self):
  390. tags = [
  391. Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000),
  392. Tag.objects.create(name='Tag 2', slug='tag-2'), # Default: 1000
  393. Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000),
  394. Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000),
  395. ]
  396. site = Site.objects.create(name='Site 1')
  397. for _tag in tags:
  398. site.tags.add(_tag)
  399. site.save()
  400. site = Site.objects.first()
  401. tags = site.tags.all()
  402. self.assertEqual(tags[0].slug, 'tag-2')
  403. self.assertEqual(tags[1].slug, 'tag-3')
  404. self.assertEqual(tags[2].slug, 'tag-4')
  405. self.assertEqual(tags[3].slug, 'tag-1')
  406. def test_create_tag_unicode(self):
  407. tag = Tag(name='Testing Unicode: 台灣')
  408. tag.save()
  409. self.assertEqual(tag.slug, 'testing-unicode-台灣')
  410. def test_object_type_validation(self):
  411. region = Region.objects.create(name='Region 1', slug='region-1')
  412. sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
  413. # Create a Tag that can only be applied to Regions
  414. tag = Tag.objects.create(name='Tag 1', slug='tag-1')
  415. tag.object_types.add(ObjectType.objects.get_by_natural_key('dcim', 'region'))
  416. # Apply the Tag to a Region
  417. region.tags.add(tag)
  418. self.assertIn(tag, region.tags.all())
  419. # Apply the Tag to a SiteGroup
  420. with self.assertRaises(AbortRequest):
  421. sitegroup.tags.add(tag)
  422. def test_tag_object_with_64bit_id(self):
  423. """
  424. Objects whose IDs exceed the 32-bit range can be tagged. Regression test for #23425.
  425. """
  426. site = Site.objects.create(id=3_000_000_000, name='Site 1', slug='site-1')
  427. tag = Tag.objects.create(name='Tag 1', slug='tag-1')
  428. site.tags.add(tag)
  429. self.assertEqual(list(Site.objects.get(pk=site.pk).tags.all()), [tag])
  430. self.assertEqual(list(Site.objects.filter(tags=tag)), [site])
  431. class ConfigContextTestCase(TestCase):
  432. """
  433. These test cases deal with the weighting, ordering, and deep merge logic of config context data.
  434. It also ensures the various config context querysets are consistent.
  435. """
  436. @classmethod
  437. def setUpTestData(cls):
  438. manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
  439. devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
  440. role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
  441. region = Region.objects.create(name='Region')
  442. sitegroup = SiteGroup.objects.create(name='Site Group')
  443. site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup)
  444. location = Location.objects.create(name='Location 1', slug='location-1', site=site)
  445. Platform.objects.create(name='Platform')
  446. tenantgroup = TenantGroup.objects.create(name='Tenant Group')
  447. Tenant.objects.create(name='Tenant', group=tenantgroup)
  448. Tag.objects.create(name='Tag', slug='tag')
  449. Tag.objects.create(name='Tag2', slug='tag2')
  450. Device.objects.create(
  451. name='Device 1',
  452. device_type=devicetype,
  453. role=role,
  454. site=site,
  455. location=location
  456. )
  457. def test_higher_weight_wins(self):
  458. device = Device.objects.first()
  459. context1 = ConfigContext(
  460. name="context 1",
  461. weight=101,
  462. data={
  463. "a": 123,
  464. "b": 456,
  465. "c": 777
  466. }
  467. )
  468. context2 = ConfigContext(
  469. name="context 2",
  470. weight=100,
  471. data={
  472. "a": 123,
  473. "b": 456,
  474. "c": 789
  475. }
  476. )
  477. ConfigContext.objects.bulk_create([context1, context2])
  478. expected_data = {
  479. "a": 123,
  480. "b": 456,
  481. "c": 777
  482. }
  483. self.assertEqual(device.get_config_context(), expected_data)
  484. def test_name_ordering_after_weight(self):
  485. device = Device.objects.first()
  486. context1 = ConfigContext(
  487. name="context 1",
  488. weight=100,
  489. data={
  490. "a": 123,
  491. "b": 456,
  492. "c": 777
  493. }
  494. )
  495. context2 = ConfigContext(
  496. name="context 2",
  497. weight=100,
  498. data={
  499. "a": 123,
  500. "b": 456,
  501. "c": 789
  502. }
  503. )
  504. ConfigContext.objects.bulk_create([context1, context2])
  505. expected_data = {
  506. "a": 123,
  507. "b": 456,
  508. "c": 789
  509. }
  510. self.assertEqual(device.get_config_context(), expected_data)
  511. def test_schema_validation(self):
  512. """
  513. Check that the JSON schema defined by the assigned profile is enforced.
  514. """
  515. profile = ConfigContextProfile.objects.create(
  516. name="Config context profile 1",
  517. schema={
  518. "properties": {
  519. "foo": {
  520. "type": "string"
  521. }
  522. },
  523. "required": [
  524. "foo"
  525. ]
  526. }
  527. )
  528. with self.assertRaises(ValidationError):
  529. # Missing required attribute
  530. ConfigContext(name="CC1", profile=profile, data={}).clean()
  531. with self.assertRaises(ValidationError):
  532. # Invalid attribute type
  533. ConfigContext(name="CC1", profile=profile, data={"foo": 123}).clean()
  534. ConfigContext(name="CC1", profile=profile, data={"foo": "bar"}).clean()
  535. def test_annotation_same_as_get_for_object(self):
  536. """
  537. This test incorporates features from all of the above tests cases to ensure
  538. the annotate_config_context_data() and get_for_object() queryset methods are the same.
  539. """
  540. device = Device.objects.first()
  541. context1 = ConfigContext(
  542. name="context 1",
  543. weight=101,
  544. data={
  545. "a": 123,
  546. "b": 456,
  547. "c": 777
  548. }
  549. )
  550. context2 = ConfigContext(
  551. name="context 2",
  552. weight=100,
  553. data={
  554. "a": 123,
  555. "b": 456,
  556. "c": 789
  557. }
  558. )
  559. context3 = ConfigContext(
  560. name="context 3",
  561. weight=99,
  562. data={
  563. "d": 1
  564. }
  565. )
  566. context4 = ConfigContext(
  567. name="context 4",
  568. weight=99,
  569. data={
  570. "d": 2
  571. }
  572. )
  573. ConfigContext.objects.bulk_create([context1, context2, context3, context4])
  574. annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
  575. self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
  576. def test_annotation_same_as_get_for_object_device_relations(self):
  577. region = Region.objects.first()
  578. sitegroup = SiteGroup.objects.first()
  579. site = Site.objects.first()
  580. location = Location.objects.first()
  581. platform = Platform.objects.first()
  582. tenantgroup = TenantGroup.objects.first()
  583. tenant = Tenant.objects.first()
  584. tag = Tag.objects.first()
  585. region_context = ConfigContext.objects.create(
  586. name="region",
  587. weight=100,
  588. data={
  589. "region": 1
  590. }
  591. )
  592. region_context.regions.add(region)
  593. sitegroup_context = ConfigContext.objects.create(
  594. name="sitegroup",
  595. weight=100,
  596. data={
  597. "sitegroup": 1
  598. }
  599. )
  600. sitegroup_context.site_groups.add(sitegroup)
  601. site_context = ConfigContext.objects.create(
  602. name="site",
  603. weight=100,
  604. data={
  605. "site": 1
  606. }
  607. )
  608. site_context.sites.add(site)
  609. location_context = ConfigContext.objects.create(
  610. name="location",
  611. weight=100,
  612. data={
  613. "location": 1
  614. }
  615. )
  616. location_context.locations.add(location)
  617. platform_context = ConfigContext.objects.create(
  618. name="platform",
  619. weight=100,
  620. data={
  621. "platform": 1
  622. }
  623. )
  624. platform_context.platforms.add(platform)
  625. tenant_group_context = ConfigContext.objects.create(
  626. name="tenant group",
  627. weight=100,
  628. data={
  629. "tenant_group": 1
  630. }
  631. )
  632. tenant_group_context.tenant_groups.add(tenantgroup)
  633. tenant_context = ConfigContext.objects.create(
  634. name="tenant",
  635. weight=100,
  636. data={
  637. "tenant": 1
  638. }
  639. )
  640. tenant_context.tenants.add(tenant)
  641. tag_context = ConfigContext.objects.create(
  642. name="tag",
  643. weight=100,
  644. data={
  645. "tag": 1
  646. }
  647. )
  648. tag_context.tags.add(tag)
  649. device = Device.objects.create(
  650. name="Device 2",
  651. site=site,
  652. location=location,
  653. tenant=tenant,
  654. platform=platform,
  655. role=DeviceRole.objects.first(),
  656. device_type=DeviceType.objects.first()
  657. )
  658. device.tags.add(tag)
  659. annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
  660. self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
  661. def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
  662. region = Region.objects.first()
  663. sitegroup = SiteGroup.objects.first()
  664. site = Site.objects.first()
  665. platform = Platform.objects.first()
  666. tenantgroup = TenantGroup.objects.first()
  667. tenant = Tenant.objects.first()
  668. tag = Tag.objects.first()
  669. cluster_type = ClusterType.objects.create(name="Cluster Type")
  670. cluster_group = ClusterGroup.objects.create(name="Cluster Group")
  671. cluster = Cluster.objects.create(
  672. name="Cluster",
  673. group=cluster_group,
  674. type=cluster_type,
  675. scope=site,
  676. )
  677. region_context = ConfigContext.objects.create(
  678. name="region",
  679. weight=100,
  680. data={"region": 1}
  681. )
  682. region_context.regions.add(region)
  683. sitegroup_context = ConfigContext.objects.create(
  684. name="sitegroup",
  685. weight=100,
  686. data={"sitegroup": 1}
  687. )
  688. sitegroup_context.site_groups.add(sitegroup)
  689. site_context = ConfigContext.objects.create(
  690. name="site",
  691. weight=100,
  692. data={"site": 1}
  693. )
  694. site_context.sites.add(site)
  695. platform_context = ConfigContext.objects.create(
  696. name="platform",
  697. weight=100,
  698. data={"platform": 1}
  699. )
  700. platform_context.platforms.add(platform)
  701. tenant_group_context = ConfigContext.objects.create(
  702. name="tenant group",
  703. weight=100,
  704. data={"tenant_group": 1}
  705. )
  706. tenant_group_context.tenant_groups.add(tenantgroup)
  707. tenant_context = ConfigContext.objects.create(
  708. name="tenant",
  709. weight=100,
  710. data={"tenant": 1}
  711. )
  712. tenant_context.tenants.add(tenant)
  713. tag_context = ConfigContext.objects.create(
  714. name="tag",
  715. weight=100,
  716. data={"tag": 1}
  717. )
  718. tag_context.tags.add(tag)
  719. cluster_type_context = ConfigContext.objects.create(
  720. name="cluster type",
  721. weight=100,
  722. data={"cluster_type": 1}
  723. )
  724. cluster_type_context.cluster_types.add(cluster_type)
  725. cluster_group_context = ConfigContext.objects.create(
  726. name="cluster group",
  727. weight=100,
  728. data={"cluster_group": 1}
  729. )
  730. cluster_group_context.cluster_groups.add(cluster_group)
  731. cluster_context = ConfigContext.objects.create(
  732. name="cluster",
  733. weight=100,
  734. data={"cluster": 1}
  735. )
  736. cluster_context.clusters.add(cluster)
  737. virtual_machine = VirtualMachine.objects.create(
  738. name="VM 1",
  739. cluster=cluster,
  740. tenant=tenant,
  741. platform=platform,
  742. role=DeviceRole.objects.first()
  743. )
  744. virtual_machine.tags.add(tag)
  745. annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
  746. self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
  747. def test_virtualmachine_site_context(self):
  748. """
  749. Check that config context associated with a site applies to a VM whether the VM is assigned
  750. directly to that site or via its cluster.
  751. """
  752. site = Site.objects.first()
  753. cluster_type = ClusterType.objects.create(name="Cluster Type")
  754. cluster = Cluster.objects.create(name="Cluster", type=cluster_type, scope=site)
  755. vm_role = DeviceRole.objects.first()
  756. # Create a ConfigContext associated with the site
  757. context = ConfigContext.objects.create(
  758. name="context1",
  759. weight=100,
  760. data={"foo": True}
  761. )
  762. context.sites.add(site)
  763. # Create one VM assigned directly to the site, and one assigned via the cluster
  764. vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
  765. vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
  766. # Check that their individually rendered config contexts are identical
  767. self.assertEqual(
  768. vm1.get_config_context(),
  769. vm2.get_config_context()
  770. )
  771. # Check that their annotated config contexts are identical
  772. vms = VirtualMachine.objects.filter(pk__in=(vm1.pk, vm2.pk)).annotate_config_context_data()
  773. self.assertEqual(
  774. vms[0].get_config_context(),
  775. vms[1].get_config_context()
  776. )
  777. def test_valid_local_context_data(self):
  778. device = Device.objects.first()
  779. device.local_context_data = None
  780. device.clean()
  781. device.local_context_data = {"foo": "bar"}
  782. device.clean()
  783. def test_invalid_local_context_data(self):
  784. device = Device.objects.first()
  785. device.local_context_data = ""
  786. with self.assertRaises(ValidationError):
  787. device.clean()
  788. device.local_context_data = 0
  789. with self.assertRaises(ValidationError):
  790. device.clean()
  791. device.local_context_data = False
  792. with self.assertRaises(ValidationError):
  793. device.clean()
  794. device.local_context_data = 'foo'
  795. with self.assertRaises(ValidationError):
  796. device.clean()
  797. @tag('regression')
  798. def test_multiple_tags_return_distinct_objects(self):
  799. """
  800. Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
  801. This is combated by appending distinct() to the config context querysets. This test creates a config
  802. context assigned to two tags and ensures objects related to those same two tags result in only a single
  803. config context record being returned.
  804. See https://github.com/netbox-community/netbox/issues/5314
  805. """
  806. site = Site.objects.first()
  807. platform = Platform.objects.first()
  808. tenant = Tenant.objects.first()
  809. tags = Tag.objects.all()
  810. tag_context = ConfigContext.objects.create(
  811. name="tag",
  812. weight=100,
  813. data={
  814. "tag": 1
  815. }
  816. )
  817. tag_context.tags.set(tags)
  818. device = Device.objects.create(
  819. name="Device 3",
  820. site=site,
  821. tenant=tenant,
  822. platform=platform,
  823. role=DeviceRole.objects.first(),
  824. device_type=DeviceType.objects.first()
  825. )
  826. device.tags.set(tags)
  827. annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
  828. self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
  829. self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
  830. @tag('regression')
  831. def test_multiple_tags_return_distinct_objects_with_separate_config_contexts(self):
  832. """
  833. Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
  834. This is combated by appending distinct() to the config context querysets. This test creates a config
  835. context assigned to two tags and ensures objects related to those same two tags result in only a single
  836. config context record being returned.
  837. This test case is separate from the above in that it deals with multiple config context objects in play.
  838. See https://github.com/netbox-community/netbox/issues/5387
  839. """
  840. site = Site.objects.first()
  841. platform = Platform.objects.first()
  842. tenant = Tenant.objects.first()
  843. tag1, tag2 = list(Tag.objects.all())
  844. tag_context_1 = ConfigContext.objects.create(
  845. name="tag-1",
  846. weight=100,
  847. data={
  848. "tag": 1
  849. }
  850. )
  851. tag_context_1.tags.add(tag1)
  852. tag_context_2 = ConfigContext.objects.create(
  853. name="tag-2",
  854. weight=100,
  855. data={
  856. "tag": 1
  857. }
  858. )
  859. tag_context_2.tags.add(tag2)
  860. device = Device.objects.create(
  861. name="Device 3",
  862. site=site,
  863. tenant=tenant,
  864. platform=platform,
  865. role=DeviceRole.objects.first(),
  866. device_type=DeviceType.objects.first()
  867. )
  868. device.tags.set([tag1, tag2])
  869. annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
  870. self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
  871. self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
  872. @tag('performance', 'regression')
  873. def test_config_context_annotation_query_optimization(self):
  874. """
  875. Regression test for issue #20327: Ensure config context annotation
  876. doesn't use expensive DISTINCT on main query.
  877. Verifies that DISTINCT is only used in tag subquery where needed,
  878. not on the main device query which is expensive for large datasets.
  879. """
  880. device = Device.objects.first()
  881. queryset = Device.objects.filter(pk=device.pk).annotate_config_context_data()
  882. # Main device query should NOT use DISTINCT
  883. self.assertFalse(queryset.query.distinct)
  884. # Check that tag subqueries DO use DISTINCT by inspecting the annotation
  885. config_annotation = queryset.query.annotations.get('config_context_data')
  886. self.assertIsNotNone(config_annotation)
  887. def find_tag_subqueries(where_node):
  888. """Find subqueries in WHERE clause that relate to tag filtering"""
  889. subqueries = []
  890. def traverse(node):
  891. if hasattr(node, 'children'):
  892. for child in node.children:
  893. try:
  894. # In Django 6.0+, rhs is a Query directly; older Django wraps it in Subquery
  895. rhs_query = getattr(child.rhs, 'query', child.rhs)
  896. if rhs_query.model is TaggedItem:
  897. subqueries.append(rhs_query)
  898. except AttributeError:
  899. traverse(child)
  900. traverse(where_node)
  901. return subqueries
  902. # In Django 6.0+, the annotation is a Query directly; older Django wraps it in Subquery
  903. annotation_query = getattr(config_annotation, 'query', config_annotation)
  904. # Find subqueries in the WHERE clause that should have DISTINCT
  905. tag_subqueries = find_tag_subqueries(annotation_query.where)
  906. distinct_subqueries = [sq for sq in tag_subqueries if sq.distinct]
  907. # Verify we found at least one DISTINCT subquery for tags
  908. self.assertEqual(len(distinct_subqueries), 1)
  909. self.assertTrue(distinct_subqueries[0].distinct)
  910. class ConfigTemplateTestCase(TestCase):
  911. """
  912. TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
  913. """
  914. MAIN_TEMPLATE = """
  915. {%- include 'base.j2' %}
  916. """.strip()
  917. BASE_TEMPLATE = """
  918. Hi
  919. """.strip()
  920. @classmethod
  921. def _create_template_file(cls, templates_dir, file_name, content):
  922. template_file_name = file_name
  923. if not template_file_name.endswith('j2'):
  924. template_file_name += '.j2'
  925. temp_file_path = templates_dir / template_file_name
  926. with open(temp_file_path, 'w') as f:
  927. f.write(content)
  928. @classmethod
  929. def setUpTestData(cls):
  930. temp_dir = tempfile.TemporaryDirectory()
  931. templates_dir = Path(temp_dir.name) / "templates"
  932. templates_dir.mkdir(parents=True, exist_ok=True)
  933. cls._create_template_file(templates_dir, 'base.j2', cls.BASE_TEMPLATE)
  934. cls._create_template_file(templates_dir, 'main.j2', cls.MAIN_TEMPLATE)
  935. data_source = DataSource(
  936. name="Test DataSource",
  937. type="local",
  938. source_url=str(templates_dir),
  939. )
  940. data_source.save()
  941. data_source.sync()
  942. base_config_template = ConfigTemplate(
  943. name="BaseTemplate",
  944. data_file=data_source.datafiles.filter(path__endswith='base.j2').first()
  945. )
  946. base_config_template.clean()
  947. base_config_template.save()
  948. cls.base_config_template = base_config_template
  949. main_config_template = ConfigTemplate(
  950. name="MainTemplate",
  951. data_file=data_source.datafiles.filter(path__endswith='main.j2').first()
  952. )
  953. main_config_template.clean()
  954. main_config_template.save()
  955. cls.main_config_template = main_config_template
  956. @tag('regression')
  957. def test_config_template_with_data_source(self):
  958. self.assertEqual(self.BASE_TEMPLATE, self.base_config_template.render({}))
  959. @tag('regression')
  960. def test_config_template_with_data_source_nested_templates(self):
  961. self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
  962. @tag('regression')
  963. def test_autosyncrecord_cleanup_on_detach(self):
  964. """Test that AutoSyncRecord is deleted when detaching from DataSource."""
  965. with tempfile.TemporaryDirectory() as temp_dir:
  966. templates_dir = Path(temp_dir) / "templates"
  967. templates_dir.mkdir(parents=True, exist_ok=True)
  968. self._create_template_file(templates_dir, 'test.j2', 'Test content')
  969. data_source = DataSource(
  970. name="Test DataSource for Detach",
  971. type="local",
  972. source_url=str(templates_dir),
  973. )
  974. data_source.save()
  975. data_source.sync()
  976. data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
  977. # Create a ConfigTemplate with data_file and auto_sync_enabled
  978. config_template = ConfigTemplate(
  979. name="TestTemplateForDetach",
  980. data_file=data_file,
  981. auto_sync_enabled=True
  982. )
  983. config_template.clean()
  984. config_template.save()
  985. # Verify AutoSyncRecord was created
  986. object_type = ObjectType.objects.get_for_model(ConfigTemplate)
  987. autosync_records = AutoSyncRecord.objects.filter(
  988. object_type=object_type,
  989. object_id=config_template.pk
  990. )
  991. self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
  992. # Detach from DataSource
  993. config_template.data_file = None
  994. config_template.data_source = None
  995. config_template.auto_sync_enabled = False
  996. config_template.clean()
  997. config_template.save()
  998. # Verify AutoSyncRecord was deleted
  999. autosync_records = AutoSyncRecord.objects.filter(
  1000. object_type=object_type,
  1001. object_id=config_template.pk
  1002. )
  1003. self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
  1004. class ConfigTemplateDebugTestCase(TestCase):
  1005. """
  1006. Tests for the ConfigTemplate debug field and its effect on template rendering error output.
  1007. """
  1008. def _make_template(self, template_code, debug=False):
  1009. t = ConfigTemplate(
  1010. name=f"DebugTestTemplate-{debug}",
  1011. template_code=template_code,
  1012. debug=debug,
  1013. )
  1014. t.save()
  1015. return t
  1016. def test_debug_default_is_false(self):
  1017. t = ConfigTemplate(name="t", template_code="hello")
  1018. self.assertFalse(t.debug)
  1019. def test_template_error_non_debug_no_traceback(self):
  1020. """In non-debug mode, a TemplateError raises with no traceback exposure."""
  1021. t = self._make_template("{{ unclosed", debug=False)
  1022. with self.assertRaises(TemplateError):
  1023. t.render({})
  1024. def test_template_error_debug_mode_raises(self):
  1025. """In debug mode, a TemplateError still raises (callers handle display)."""
  1026. t = self._make_template("{{ unclosed", debug=True)
  1027. with self.assertRaises(TemplateError):
  1028. t.render({})
  1029. def test_render_jinja2_debug_extension_enabled(self):
  1030. """When debug=True, the Jinja2 debug extension is loaded in the environment."""
  1031. # The {% debug %} tag is only available when the debug extension is loaded.
  1032. output = render_jinja2("{% debug %}", {}, debug=True)
  1033. self.assertIsInstance(output, str)
  1034. def test_render_jinja2_debug_extension_not_loaded_by_default(self):
  1035. """When debug=False, the {% debug %} tag is not available."""
  1036. with self.assertRaises(TemplateSyntaxError):
  1037. render_jinja2("{% debug %}", {}, debug=False)
  1038. def test_format_render_error_debug_redacts_install_path(self):
  1039. """format_render_error() strips the repo install-path prefix from debug tracebacks."""
  1040. t = ConfigTemplate(name='redact-test', template_code='hello', debug=True)
  1041. try:
  1042. raise ValueError("deliberate test error")
  1043. except ValueError as exc:
  1044. result = t.format_render_error(exc)
  1045. install_root = os.path.dirname(settings.BASE_DIR) + os.sep
  1046. self.assertIn('Traceback', result)
  1047. self.assertNotIn(install_root, result)
  1048. # Also verify the venv prefix is stripped when running inside a virtualenv.
  1049. if sys.prefix != sys.base_prefix:
  1050. venv_root = sys.prefix + os.sep
  1051. if venv_root != install_root:
  1052. self.assertNotIn(venv_root, result)
  1053. def test_format_render_error_non_debug_returns_concise_message(self):
  1054. """format_render_error() returns a one-line message (no traceback) when debug=False."""
  1055. t = ConfigTemplate(name='nodebug-test', template_code='hello', debug=False)
  1056. try:
  1057. raise TemplateError("bad template")
  1058. except TemplateError as exc:
  1059. result = t.format_render_error(exc)
  1060. self.assertNotIn('Traceback', result)
  1061. self.assertIn('TemplateError', result)
  1062. class JinjaEnvFilterTestCase(TestCase):
  1063. """
  1064. Tests for the env() Jinja2 filter and the JINJA_ENVIRONMENT_PARAMS configuration parameter.
  1065. """
  1066. def test_env_filter_returns_value_for_matching_name(self):
  1067. with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
  1068. self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
  1069. self.assertEqual(env_filter('NETBOX_TEST_TOKEN'), 'secret')
  1070. def test_env_filter_returns_none_for_unmatched_name(self):
  1071. with patch.dict('os.environ', {'NETBOX_OTHER_TOKEN': 'secret'}, clear=False), \
  1072. self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
  1073. self.assertIsNone(env_filter('NETBOX_OTHER_TOKEN'))
  1074. def test_env_filter_wildcard_match(self):
  1075. with patch.dict('os.environ', {'NETBOX_TEST_TOKEN_1': 'one', 'NETBOX_TEST_TOKEN_2': 'two'}, clear=False), \
  1076. self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN_*']):
  1077. self.assertEqual(env_filter('NETBOX_TEST_TOKEN_1'), 'one')
  1078. self.assertEqual(env_filter('NETBOX_TEST_TOKEN_2'), 'two')
  1079. def test_env_filter_returns_none_for_missing_env_var(self):
  1080. with self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_MISSING_VAR']):
  1081. self.assertIsNone(env_filter('NETBOX_MISSING_VAR'))
  1082. def test_env_filter_empty_whitelist_returns_none(self):
  1083. with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
  1084. self.settings(JINJA_ENVIRONMENT_PARAMS=[]):
  1085. self.assertIsNone(env_filter('NETBOX_TEST_TOKEN'))
  1086. def test_env_filter_registered_by_default(self):
  1087. with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
  1088. self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
  1089. output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
  1090. self.assertEqual(output, 'secret')
  1091. def test_user_defined_filter_overrides_default(self):
  1092. with self.settings(JINJA_FILTERS={'env': lambda name: 'overridden'}):
  1093. output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
  1094. self.assertEqual(output, 'overridden')
  1095. class SanitizeHTTPHeaderFilterTestCase(TestCase):
  1096. """
  1097. Tests for the sanitize_http_header() Jinja2 filter (exposed as `header_safe`) and the render_jinja2()
  1098. `filters` argument used to make it available.
  1099. """
  1100. def test_strips_crlf(self):
  1101. self.assertEqual(sanitize_http_header('legit\r\nX-Injected: evil'), 'legitX-Injected: evil')
  1102. def test_strips_control_characters(self):
  1103. self.assertEqual(sanitize_http_header('foo\x00\x1f\x7fbar'), 'foobar')
  1104. def test_preserves_normal_value(self):
  1105. self.assertEqual(sanitize_http_header('application/json'), 'application/json')
  1106. def test_coerces_non_string(self):
  1107. self.assertEqual(sanitize_http_header(42), '42')
  1108. def test_available_via_render_filters_argument(self):
  1109. output = render_jinja2(
  1110. "{{ value | header_safe }}",
  1111. {'value': 'a\r\nb'},
  1112. filters={'header_safe': sanitize_http_header},
  1113. )
  1114. self.assertEqual(output, 'ab')
  1115. def test_render_filters_take_precedence_over_user_config(self):
  1116. # A per-render filter cannot be shadowed by a user-configured filter of the same name
  1117. with self.settings(JINJA_FILTERS={'header_safe': lambda v: 'shadowed'}):
  1118. output = render_jinja2(
  1119. "{{ value | header_safe }}",
  1120. {'value': 'a\r\nb'},
  1121. filters={'header_safe': sanitize_http_header},
  1122. )
  1123. self.assertEqual(output, 'ab')
  1124. def test_not_registered_without_filters_argument(self):
  1125. # The filter must not leak into general-purpose rendering
  1126. with self.assertRaises(TemplateError):
  1127. render_jinja2("{{ 'x' | header_safe }}", {})
  1128. class ExportTemplateContextTestCase(TestCase):
  1129. """
  1130. Tests for ExportTemplate.get_context() including public model population.
  1131. """
  1132. def test_get_context_includes_public_models(self):
  1133. et = ExportTemplate(name='test', template_code='test')
  1134. ctx = et.get_context()
  1135. self.assertIs(ctx['dcim']['Site'], Site)
  1136. self.assertIs(ctx['dcim']['Device'], Device)
  1137. def test_get_context_includes_queryset(self):
  1138. Site.objects.create(name='Site 1', slug='site-1')
  1139. et = ExportTemplate(name='test', template_code='test')
  1140. qs = Site.objects.all()
  1141. ctx = et.get_context(queryset=qs)
  1142. self.assertQuerySetEqual(ctx['queryset'], qs)
  1143. self.assertIs(ctx['queryset'][0]._state.fetch_mode, FETCH_PEERS)
  1144. def test_get_context_applies_extra_context(self):
  1145. et = ExportTemplate(name='test', template_code='test')
  1146. ctx = et.get_context(context={'custom_key': 'custom_value'})
  1147. self.assertEqual(ctx['custom_key'], 'custom_value')
  1148. self.assertIs(ctx['dcim']['Site'], Site)
  1149. def test_config_template_get_context_includes_public_models(self):
  1150. ct = ConfigTemplate(name='test', template_code='test')
  1151. ctx = ct.get_context()
  1152. self.assertIs(ctx['dcim']['Site'], Site)
  1153. def finalize_none_to_dash(value):
  1154. """
  1155. Module-level helper used by RenderTemplateMixinRenderTestCase.test_environment_params_finalize_path_import.
  1156. Exported so it can be referenced by dotted path from a Jinja environment_params value.
  1157. """
  1158. return '-' if value is None else value
  1159. class RenderTemplateMixinRenderTestCase(TestCase):
  1160. """
  1161. Tests for RenderTemplateMixin.render() and get_environment_params(), exercised via ConfigTemplate.
  1162. """
  1163. def test_render_basic_context(self):
  1164. t = ConfigTemplate(name='basic', template_code='Hello {{ name }}')
  1165. self.assertEqual(t.render({'name': 'world'}), 'Hello world')
  1166. def test_render_normalizes_crlf(self):
  1167. t = ConfigTemplate(name='crlf', template_code='line1\r\nline2\r\nline3')
  1168. self.assertEqual(t.render({}), 'line1\nline2\nline3')
  1169. def test_render_passes_environment_params(self):
  1170. # With trim_blocks + lstrip_blocks, block tags don't emit their surrounding whitespace.
  1171. template_code = '{% if x %}\n {% if y %}\n VALUE\n {% endif %}\n{% endif %}'
  1172. plain = ConfigTemplate(name='plain', template_code=template_code)
  1173. trimmed = ConfigTemplate(
  1174. name='trimmed',
  1175. template_code=template_code,
  1176. environment_params={'trim_blocks': True, 'lstrip_blocks': True},
  1177. )
  1178. ctx = {'x': True, 'y': True}
  1179. self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
  1180. self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
  1181. def test_configtemplate_autoescape_always_disabled(self):
  1182. """
  1183. ConfigTemplate renders plain text (network configs, scripts); autoescape must stay off
  1184. even if environment_params explicitly requests it (#22652).
  1185. """
  1186. t = ConfigTemplate(name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True})
  1187. self.assertEqual(t.render({'value': '<script>'}), '<script>')
  1188. def test_exporttemplate_autoescape_is_configurable(self):
  1189. """
  1190. Unlike ConfigTemplate, ExportTemplate output may legitimately be HTML, so an explicit
  1191. autoescape=True in environment_params must be honored rather than forced off.
  1192. """
  1193. et = ExportTemplate(
  1194. name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True}
  1195. )
  1196. self.assertEqual(et.render({'value': '<script>'}), '&lt;script&gt;')
  1197. def test_environment_params_undefined_path_import(self):
  1198. # Default Undefined renders nothing for a missing variable.
  1199. default = ConfigTemplate(name='default', template_code='{{ missing }}')
  1200. self.assertEqual(default.render({}), '')
  1201. # StrictUndefined (resolved from its dotted path) raises on access.
  1202. strict = ConfigTemplate(
  1203. name='strict',
  1204. template_code='{{ missing }}',
  1205. environment_params={'undefined': 'jinja2.StrictUndefined'},
  1206. )
  1207. with self.assertRaises(UndefinedError):
  1208. strict.render({})
  1209. def test_environment_params_stored_finalize_resolution(self):
  1210. """
  1211. A stored 'finalize' value still resolves via import_string(). clean() blocks new use (CVE-2026-29514).
  1212. """
  1213. # create() does not call clean(). This matches a value stored before clean() blocked it.
  1214. t = ConfigTemplate.objects.create(
  1215. name='finalize',
  1216. template_code='{{ v }}',
  1217. environment_params={'finalize': 'extras.tests.test_models.finalize_none_to_dash'},
  1218. )
  1219. self.assertEqual(t.render({'v': None}), '-')
  1220. self.assertEqual(t.render({'v': 'abc'}), 'abc')
  1221. def test_get_environment_params_handles_none(self):
  1222. # The environment_params field may be cleared; ensure the mixin returns a dict (not None).
  1223. # ConfigTemplate always forces autoescape off (#22652).
  1224. t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
  1225. self.assertEqual(t.get_environment_params(), {'autoescape': False})
  1226. def test_get_environment_params_resolves_path_imports(self):
  1227. t = ConfigTemplate(
  1228. name='resolve',
  1229. template_code='ok',
  1230. environment_params={'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True},
  1231. )
  1232. params = t.get_environment_params()
  1233. self.assertIs(params['undefined'], StrictUndefined)
  1234. self.assertIs(params['trim_blocks'], True)
  1235. def test_get_environment_params_does_not_mutate_field(self):
  1236. # Resolving path imports must not replace the string values stored on the model field.
  1237. t = ConfigTemplate(
  1238. name='no-mutate',
  1239. template_code='ok',
  1240. environment_params={'undefined': 'jinja2.StrictUndefined'},
  1241. )
  1242. t.get_environment_params()
  1243. t.get_environment_params()
  1244. self.assertEqual(t.environment_params, {'undefined': 'jinja2.StrictUndefined'})
  1245. class RenderTemplateMixinResponseTestCase(TestCase):
  1246. """
  1247. Tests for RenderTemplateMixin.render_to_response() HTTP behavior.
  1248. """
  1249. def test_response_default_mime_type(self):
  1250. t = ConfigTemplate(name='t', template_code='ok')
  1251. response = t.render_to_response({})
  1252. self.assertEqual(response.status_code, 200)
  1253. self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
  1254. def test_response_custom_mime_type(self):
  1255. t = ConfigTemplate(name='t', template_code='{}', mime_type='application/json')
  1256. response = t.render_to_response({})
  1257. self.assertEqual(response['Content-Type'], 'application/json')
  1258. def test_response_attachment_with_file_name(self):
  1259. t = ConfigTemplate(
  1260. name='t', template_code='ok', file_name='router1', file_extension='cfg', as_attachment=True,
  1261. )
  1262. response = t.render_to_response({})
  1263. self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1.cfg"')
  1264. def test_response_attachment_filename_from_queryset(self):
  1265. Site.objects.create(name='Site 1', slug='site-1')
  1266. t = ExportTemplate(
  1267. name='t',
  1268. template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
  1269. file_extension='txt',
  1270. as_attachment=True,
  1271. )
  1272. response = t.render_to_response(queryset=Site.objects.all())
  1273. self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
  1274. def test_response_attachment_filename_from_empty_queryset(self):
  1275. """An empty (but non-None) queryset must still yield a model-derived filename."""
  1276. t = ExportTemplate(
  1277. name='t',
  1278. template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
  1279. file_extension='txt',
  1280. as_attachment=True,
  1281. )
  1282. response = t.render_to_response(queryset=Site.objects.none())
  1283. self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
  1284. def test_response_attachment_does_not_force_queryset_evaluation(self):
  1285. """A template that never references `queryset` must not force it to be evaluated."""
  1286. Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(5)])
  1287. t = ExportTemplate(
  1288. name='t',
  1289. template_code='static output', # deliberately does not reference `queryset`
  1290. file_extension='txt',
  1291. as_attachment=True,
  1292. )
  1293. with CaptureQueriesContext(connection) as ctx:
  1294. t.render_to_response(queryset=Site.objects.all())
  1295. table = Site._meta.db_table
  1296. site_queries = [q for q in ctx.captured_queries if table in q['sql']]
  1297. self.assertEqual(
  1298. site_queries, [],
  1299. f"render_to_response() queried {table} even though the template never "
  1300. f"references `queryset`:\n{site_queries}"
  1301. )
  1302. def test_response_attachment_filename_from_device_context(self):
  1303. t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
  1304. device = SimpleNamespace(name='router1')
  1305. response = t.render_to_response(context={'device': device})
  1306. self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1"')
  1307. def test_response_attachment_fallback_filename(self):
  1308. # No file_name, no queryset, no device/vm key in context: filename falls back to "output".
  1309. t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
  1310. response = t.render_to_response({})
  1311. self.assertEqual(response['Content-Disposition'], 'attachment; filename="output"')
  1312. def test_response_as_attachment_false_omits_disposition(self):
  1313. t = ConfigTemplate(name='t', template_code='ok', file_name='router1', as_attachment=False)
  1314. response = t.render_to_response({})
  1315. self.assertNotIn('Content-Disposition', response)
  1316. def test_response_body_matches_render(self):
  1317. t = ConfigTemplate(name='t', template_code='Hello {{ name }}')
  1318. rendered = t.render({'name': 'world'})
  1319. response = t.render_to_response({'name': 'world'})
  1320. self.assertEqual(response.content.decode(), rendered)
  1321. class ExportTemplateRenderTestCase(TestCase):
  1322. """
  1323. Tests for ExportTemplate.render() with a queryset bound into the template context.
  1324. """
  1325. @classmethod
  1326. def setUpTestData(cls):
  1327. Site.objects.bulk_create([
  1328. Site(name='Site A', slug='site-a'),
  1329. Site(name='Site B', slug='site-b'),
  1330. Site(name='Site C', slug='site-c'),
  1331. ])
  1332. def test_render_iterates_queryset(self):
  1333. t = ExportTemplate(
  1334. name='sites',
  1335. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  1336. )
  1337. queryset = Site.objects.order_by('name')
  1338. output = t.render(queryset=queryset)
  1339. self.assertEqual(output, 'Site A\nSite B\nSite C\n')
  1340. def test_render_batches_related_object_queries(self):
  1341. """
  1342. Each relation the template reads (including chained relations) costs one query for the whole queryset.
  1343. """
  1344. tenant_group = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
  1345. for i, site in enumerate(Site.objects.order_by('name'), start=1):
  1346. site.tenant = Tenant.objects.create(name=f'Tenant {i}', slug=f'tenant-{i}', group=tenant_group)
  1347. site.save()
  1348. baseline = ExportTemplate(
  1349. name='names',
  1350. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  1351. )
  1352. t = ExportTemplate(
  1353. name='related',
  1354. template_code='{% for obj in queryset %}{{ obj }},{{ obj.tenant }},{{ obj.tenant.group }}\n{% endfor %}',
  1355. )
  1356. with CaptureQueriesContext(connection) as baseline_queries:
  1357. baseline.render(queryset=Site.objects.order_by('name'))
  1358. with CaptureQueriesContext(connection) as queries:
  1359. output = t.render(queryset=Site.objects.order_by('name'))
  1360. self.assertEqual(
  1361. output,
  1362. 'Site A,Tenant 1,Tenant Group 1\nSite B,Tenant 2,Tenant Group 1\nSite C,Tenant 3,Tenant Group 1\n'
  1363. )
  1364. # One query for the tenants and one for their group
  1365. self.assertEqual(len(queries), len(baseline_queries) + 2)
  1366. def test_render_to_response_for_queryset(self):
  1367. t = ExportTemplate(
  1368. name='sites',
  1369. template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
  1370. file_extension='txt',
  1371. )
  1372. response = t.render_to_response(queryset=Site.objects.order_by('name'))
  1373. self.assertEqual(response.status_code, 200)
  1374. self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
  1375. self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
  1376. self.assertEqual(response.content.decode(), 'Site A\nSite B\nSite C\n')
  1377. class ExportTemplateStoredOnlyFinalizeTestCase(TestCase):
  1378. """
  1379. Tests that render() honors 'finalize' only with the value stored on the template's database record.
  1380. """
  1381. TEMPLATE_CODE = '{{ "netbox" }}'
  1382. def _create_stored(self):
  1383. """
  1384. Create a template with a stored 'finalize' value.
  1385. """
  1386. # create() does not call clean(). This matches a value stored before clean() blocked it.
  1387. return ExportTemplate.objects.create(
  1388. name='stored',
  1389. template_code=self.TEMPLATE_CODE,
  1390. environment_params={'finalize': 'builtins.len'},
  1391. )
  1392. @tag('regression')
  1393. def test_unsaved_template_ignores_finalize(self):
  1394. t = ExportTemplate(
  1395. name='unsaved',
  1396. template_code=self.TEMPLATE_CODE,
  1397. environment_params={'finalize': 'builtins.len'},
  1398. )
  1399. with self.assertLogs('extras.models.mixins', 'WARNING'):
  1400. self.assertEqual(t.render(), 'netbox')
  1401. def test_stored_finalize_is_applied(self):
  1402. self._create_stored()
  1403. t = ExportTemplate.objects.get(name='stored')
  1404. self.assertEqual(t.render(), '6')
  1405. @tag('regression')
  1406. def test_finalize_added_in_memory_is_ignored(self):
  1407. t = ExportTemplate.objects.create(name='saved', template_code=self.TEMPLATE_CODE)
  1408. t.environment_params = {'finalize': 'builtins.len'}
  1409. with self.assertLogs('extras.models.mixins', 'WARNING'):
  1410. self.assertEqual(t.render(), 'netbox')
  1411. @tag('regression')
  1412. def test_finalize_changed_in_memory_uses_stored_value(self):
  1413. t = self._create_stored()
  1414. t.environment_params = {'finalize': 'builtins.repr'}
  1415. with self.assertNoLogs('extras.models.mixins', 'WARNING'):
  1416. self.assertEqual(t.render(), '6')
  1417. @tag('regression')
  1418. def test_render_fails_when_template_code_differs_from_stored(self):
  1419. t = self._create_stored()
  1420. t.template_code = '{{ "changed" }}'
  1421. with self.assertRaises(TemplateError):
  1422. t.render()
  1423. @tag('regression')
  1424. def test_render_fails_when_stored_template_is_edited_after_load(self):
  1425. t = self._create_stored()
  1426. # An edit that saves new code must also remove 'finalize', since clean() rejects it
  1427. ExportTemplate.objects.filter(pk=t.pk).update(template_code='{{ "edited" }}', environment_params={})
  1428. with self.assertRaises(TemplateError):
  1429. t.render()
  1430. @tag('regression')
  1431. def test_render_fails_when_stored_template_is_deleted_after_load(self):
  1432. t = self._create_stored()
  1433. ExportTemplate.objects.filter(pk=t.pk).delete()
  1434. with self.assertRaises(TemplateError):
  1435. t.render()
  1436. def test_no_query_without_finalize(self):
  1437. t = ExportTemplate.objects.create(
  1438. name='plain',
  1439. template_code=self.TEMPLATE_CODE,
  1440. environment_params={'trim_blocks': True},
  1441. )
  1442. with self.assertNumQueries(0):
  1443. t.get_environment_params()
  1444. def test_one_query_with_stored_finalize(self):
  1445. t = self._create_stored()
  1446. with self.assertNumQueries(1):
  1447. t.get_environment_params()
  1448. class WebhookPayloadUrlValidationTestCase(TestCase):
  1449. """Tests for Webhook.clean()'s validation of payload_url (#22828)."""
  1450. def test_payload_url_accepts_literal_url(self):
  1451. webhook = Webhook(name='Webhook 1', payload_url='http://example.com/hook')
  1452. webhook.clean()
  1453. def test_payload_url_rejects_non_url(self):
  1454. webhook = Webhook(name='Webhook 1', payload_url='not-a-url-at-all')
  1455. with self.assertRaises(ValidationError) as cm:
  1456. webhook.clean()
  1457. self.assertIn('payload_url', cm.exception.message_dict)
  1458. def test_payload_url_rejects_disallowed_scheme(self):
  1459. webhook = Webhook(name='Webhook 1', payload_url='file:///etc/passwd')
  1460. with self.assertRaises(ValidationError) as cm:
  1461. webhook.clean()
  1462. self.assertIn('payload_url', cm.exception.message_dict)
  1463. def test_payload_url_accepts_jinja2_template(self):
  1464. """A templated payload_url must not be rejected merely for not being a literal URL."""
  1465. webhook = Webhook(name='Webhook 1', payload_url='http://{{ data.name }}.example.com/hook')
  1466. webhook.clean()
  1467. def test_payload_url_accepts_template_using_a_registered_filter(self):
  1468. webhook = Webhook(name='Webhook 1', payload_url="http://example.com/{{ 'HOME' | env }}")
  1469. webhook.clean()
  1470. def test_payload_url_rejects_malformed_template_syntax(self):
  1471. webhook = Webhook(name='Webhook 1', payload_url='http://{{ data.name }.example.com/hook')
  1472. with self.assertRaises(ValidationError) as cm:
  1473. webhook.clean()
  1474. self.assertIn('payload_url', cm.exception.message_dict)
  1475. def test_payload_url_rejects_template_with_unregistered_filter(self):
  1476. webhook = Webhook(
  1477. name='Webhook 1', payload_url='http://example.com/{{ data.name | totally_unregistered_filter }}'
  1478. )
  1479. with self.assertRaises(ValidationError) as cm:
  1480. webhook.clean()
  1481. self.assertIn('payload_url', cm.exception.message_dict)
  1482. def test_payload_url_accepts_single_label_host(self):
  1483. """A Docker/Kubernetes-style internal service name is a legitimate webhook target (#22832)."""
  1484. webhook = Webhook(name='Webhook 1', payload_url='http://webhook-receiver:8080/hook')
  1485. webhook.clean()
  1486. def test_payload_url_accepts_underscore_in_hostname(self):
  1487. """requests accepts an underscore in a hostname even though Django's URLValidator does not (#22832)."""
  1488. webhook = Webhook(name='Webhook 1', payload_url='http://my_host.example.com/hook')
  1489. webhook.clean()
  1490. def test_payload_url_rejects_missing_host(self):
  1491. webhook = Webhook(name='Webhook 1', payload_url='http:///hook')
  1492. with self.assertRaises(ValidationError) as cm:
  1493. webhook.clean()
  1494. self.assertIn('payload_url', cm.exception.message_dict)
  1495. def test_payload_url_rejects_templated_disallowed_scheme(self):
  1496. """A literal, disallowed scheme must be rejected even when the rest of the URL is templated (#22832)."""
  1497. webhook = Webhook(name='Webhook 1', payload_url='file:///{{ data.name }}')
  1498. with self.assertRaises(ValidationError) as cm:
  1499. webhook.clean()
  1500. self.assertIn('payload_url', cm.exception.message_dict)
  1501. def test_blank_payload_url_produces_a_single_error(self):
  1502. """clean() must not add its own error on top of clean_fields()'s for a blank value (#22832)."""
  1503. webhook = Webhook(name='Webhook 1', payload_url='')
  1504. with self.assertRaises(ValidationError) as cm:
  1505. webhook.full_clean()
  1506. self.assertEqual(cm.exception.message_dict['payload_url'], ['This field cannot be blank.'])
  1507. def test_none_payload_url_does_not_raise_typeerror(self):
  1508. webhook = Webhook(name='Webhook 1', payload_url=None)
  1509. webhook.clean()
  1510. def test_payload_url_accepts_fully_templated_value(self):
  1511. """A value with no literal scheme at all (the scheme itself is templated) must still be usable (#22832)."""
  1512. webhook = Webhook(name='Webhook 1', payload_url='{{ data.custom_fields.callback_url }}')
  1513. webhook.clean()
  1514. def test_payload_url_rejects_malformed_bracketed_host_gracefully(self):
  1515. """A malformed netloc must raise ValidationError, not an uncaught ValueError from urlsplit() (#22832)."""
  1516. webhook = Webhook(name='Webhook 1', payload_url='http://[2001:db8::1/hook')
  1517. with self.assertRaises(ValidationError) as cm:
  1518. webhook.clean()
  1519. self.assertIn('payload_url', cm.exception.message_dict)
  1520. class EventRuleTestCase(TestCase):
  1521. def test_action_data_clean_accepts_dict(self):
  1522. """
  1523. clean() should accept a JSON object (or null) as action_data.
  1524. """
  1525. webhook = Webhook.objects.create(name='Action Data Test Webhook', payload_url='http://localhost:9000/')
  1526. webhook_type = ObjectType.objects.get_for_model(Webhook)
  1527. for value in ({'key': 'value'}, None):
  1528. rule = EventRule(
  1529. name='test',
  1530. event_types=[OBJECT_CREATED],
  1531. action_data=value,
  1532. action_object_type=webhook_type,
  1533. action_object_id=webhook.pk,
  1534. )
  1535. rule.clean()
  1536. def test_action_data_clean_rejects_non_dict(self):
  1537. """
  1538. clean() should reject action_data that is valid JSON but not an object (#21989).
  1539. """
  1540. for value in ('test', 42, [1, 2, 3], True):
  1541. rule = EventRule(name='test', event_types=[OBJECT_CREATED], action_data=value)
  1542. with self.assertRaises(ValidationError) as cm:
  1543. rule.clean()
  1544. self.assertIn('action_data', cm.exception.message_dict)
  1545. class JinjaEnvironmentParamsCleanTestCase(TestCase):
  1546. """Tests for RenderTemplateMixin.clean() validation of environment_params."""
  1547. def _make_template(self, environment_params):
  1548. return ConfigTemplate(
  1549. name='test',
  1550. template_code='{{ "test" }}',
  1551. environment_params=environment_params,
  1552. )
  1553. def test_allowed_scalar_params_pass(self):
  1554. template = self._make_template({'trim_blocks': True, 'lstrip_blocks': True})
  1555. template.clean()
  1556. def test_autoescape_boolean_passes(self):
  1557. template = self._make_template({'autoescape': True})
  1558. template.clean()
  1559. def test_valid_undefined_passes(self):
  1560. for value in (
  1561. 'jinja2.Undefined',
  1562. 'jinja2.ChainableUndefined',
  1563. 'jinja2.DebugUndefined',
  1564. 'jinja2.StrictUndefined',
  1565. ):
  1566. template = self._make_template({'undefined': value})
  1567. template.clean()
  1568. def test_invalid_undefined_rejected(self):
  1569. template = self._make_template({'undefined': 'subprocess.getoutput'})
  1570. with self.assertRaises(ValidationError) as cm:
  1571. template.clean()
  1572. self.assertIn('environment_params', cm.exception.message_dict)
  1573. def test_unknown_key_rejected(self):
  1574. template = self._make_template({'extensions': ['os']})
  1575. with self.assertRaises(ValidationError) as cm:
  1576. template.clean()
  1577. self.assertIn('environment_params', cm.exception.message_dict)
  1578. def test_finalize_blocked_from_new_use(self):
  1579. template = self._make_template({'finalize': 'subprocess.getoutput'})
  1580. with self.assertRaises(ValidationError) as cm:
  1581. template.clean()
  1582. self.assertIn('environment_params', cm.exception.message_dict)
  1583. def test_empty_params_pass(self):
  1584. template = self._make_template({})
  1585. template.clean()
  1586. def test_none_params_pass(self):
  1587. template = self._make_template(None)
  1588. template.clean()
  1589. def test_exporttemplate_clean_rejects_unknown_key(self):
  1590. """MRO smoke test: ExportTemplate.clean() reaches RenderTemplateMixin.clean()."""
  1591. obj = ExportTemplate(
  1592. name='test',
  1593. template_code='{{ "test" }}',
  1594. environment_params={'loader': 'some.loader'},
  1595. )
  1596. with self.assertRaises(ValidationError) as cm:
  1597. obj.clean()
  1598. self.assertIn('environment_params', cm.exception.message_dict)
  1599. def test_configtemplate_clean_rejects_finalize(self):
  1600. """MRO smoke test: ConfigTemplate.clean() reaches RenderTemplateMixin.clean()."""
  1601. obj = ConfigTemplate(
  1602. name='test',
  1603. template_code='{{ "test" }}',
  1604. environment_params={'finalize': 'subprocess.getoutput'},
  1605. )
  1606. with self.assertRaises(ValidationError) as cm:
  1607. obj.clean()
  1608. self.assertIn('environment_params', cm.exception.message_dict)
  1609. class JinjaEnvironmentParamsFilterTestCase(TestCase):
  1610. """Tests for RenderTemplateMixin._filter_environment_params()."""
  1611. def test_allowed_keys_pass_through(self):
  1612. params = {'trim_blocks': True, 'autoescape': False}
  1613. result = RenderTemplateMixin._filter_environment_params(params)
  1614. self.assertEqual(result, params)
  1615. def test_unknown_keys_stripped(self):
  1616. params = {'extensions': ['os'], 'loader': 'x', 'trim_blocks': True}
  1617. result = RenderTemplateMixin._filter_environment_params(params)
  1618. self.assertEqual(result, {'trim_blocks': True})
  1619. def test_finalize_preserved_as_legacy(self):
  1620. params = {'finalize': 'some.module.func', 'trim_blocks': True}
  1621. result = RenderTemplateMixin._filter_environment_params(params)
  1622. self.assertEqual(result, params)
  1623. def test_empty_params(self):
  1624. self.assertEqual(RenderTemplateMixin._filter_environment_params({}), {})
  1625. class JinjaEnvironmentParamsResolveTestCase(TestCase):
  1626. """Tests for RenderTemplateMixin._resolve_mapped_params()."""
  1627. def test_undefined_resolved_to_class(self):
  1628. params = {'undefined': 'jinja2.StrictUndefined'}
  1629. result = RenderTemplateMixin._resolve_mapped_params(params)
  1630. self.assertIs(result['undefined'], StrictUndefined)
  1631. def test_unrecognized_undefined_value_passed_through(self):
  1632. params = {'undefined': 'not.a.real.class'}
  1633. result = RenderTemplateMixin._resolve_mapped_params(params)
  1634. self.assertEqual(result['undefined'], 'not.a.real.class')
  1635. def test_scalar_params_passed_through(self):
  1636. params = {'trim_blocks': True, 'autoescape': False}
  1637. result = RenderTemplateMixin._resolve_mapped_params(params)
  1638. self.assertEqual(result, params)
  1639. def test_empty_params(self):
  1640. self.assertEqual(RenderTemplateMixin._resolve_mapped_params({}), {})
  1641. class JinjaEnvironmentParamsFinalizeTestCase(TestCase):
  1642. """Tests for RenderTemplateMixin._resolve_finalize() legacy carve-out."""
  1643. def test_finalize_string_resolved_via_import_string(self):
  1644. params = {'finalize': 'extras.tests.test_models.finalize_none_to_dash'}
  1645. result = RenderTemplateMixin._resolve_finalize(params)
  1646. self.assertIs(result['finalize'], finalize_none_to_dash)
  1647. def test_finalize_non_string_passed_through(self):
  1648. params = {'finalize': 42}
  1649. result = RenderTemplateMixin._resolve_finalize(params)
  1650. self.assertEqual(result['finalize'], 42)
  1651. def test_no_finalize_key_unchanged(self):
  1652. params = {'trim_blocks': True}
  1653. result = RenderTemplateMixin._resolve_finalize(params)
  1654. self.assertEqual(result, {'trim_blocks': True})
  1655. def test_invalid_import_path_raises_import_error(self):
  1656. params = {'finalize': 'nonexistent.module.func'}
  1657. with self.assertRaises(ImportError):
  1658. RenderTemplateMixin._resolve_finalize(params)
  1659. def test_empty_params(self):
  1660. self.assertEqual(RenderTemplateMixin._resolve_finalize({}), {})
  1661. class JinjaEnvironmentParamsIntegrationTestCase(TestCase):
  1662. """Integration tests for get_environment_params() end-to-end."""
  1663. def _make_template(self, environment_params):
  1664. return ConfigTemplate(
  1665. name='test',
  1666. template_code='{{ "test" }}',
  1667. environment_params=environment_params,
  1668. )
  1669. def test_full_pipeline_with_undefined(self):
  1670. template = self._make_template({'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True})
  1671. params = template.get_environment_params()
  1672. self.assertIs(params['undefined'], StrictUndefined)
  1673. self.assertIs(params['trim_blocks'], True)
  1674. def test_full_pipeline_strips_unknown_and_resolves(self):
  1675. template = self._make_template({
  1676. 'extensions': ['os'],
  1677. 'undefined': 'jinja2.DebugUndefined',
  1678. 'trim_blocks': True,
  1679. })
  1680. params = template.get_environment_params()
  1681. self.assertNotIn('extensions', params)
  1682. self.assertIs(params['undefined'], DebugUndefined)
  1683. self.assertIs(params['trim_blocks'], True)
  1684. def test_full_pipeline_finalize_resolves(self):
  1685. # Only a stored 'finalize' value resolves (#23375).
  1686. template = self._make_template({
  1687. 'finalize': 'extras.tests.test_models.finalize_none_to_dash',
  1688. })
  1689. template.save()
  1690. params = template.get_environment_params()
  1691. self.assertIs(params['finalize'], finalize_none_to_dash)
  1692. def test_does_not_mutate_stored_value(self):
  1693. template = self._make_template({'undefined': 'jinja2.StrictUndefined'})
  1694. template.get_environment_params()
  1695. self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
  1696. def test_none_environment_params(self):
  1697. # ConfigTemplate always forces autoescape off (#22652).
  1698. template = self._make_template(None)
  1699. self.assertEqual(template.get_environment_params(), {'autoescape': False})
  1700. def test_empty_environment_params(self):
  1701. # ConfigTemplate always forces autoescape off (#22652).
  1702. template = self._make_template({})
  1703. self.assertEqual(template.get_environment_params(), {'autoescape': False})
  1704. @override_settings(RQ_DEFAULT_TIMEOUT=300)
  1705. class WebhookTestCase(TestCase):
  1706. def test_timeout_must_be_less_than_job_timeout(self):
  1707. """
  1708. A timeout at or above RQ_DEFAULT_TIMEOUT leaves no room for the request's own timeout to apply, and
  1709. is rejected.
  1710. """
  1711. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/')
  1712. for timeout in (300, 301):
  1713. webhook.timeout = timeout
  1714. with self.assertRaises(ValidationError):
  1715. webhook.full_clean()
  1716. def test_timeout_below_job_timeout_is_valid(self):
  1717. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=299)
  1718. webhook.full_clean()
  1719. def test_null_timeout_is_valid(self):
  1720. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/')
  1721. webhook.full_clean()
  1722. @override_settings(RQ_DEFAULT_TIMEOUT='1h')
  1723. def test_job_timeout_duration_string_is_validated(self):
  1724. """
  1725. RQ also accepts a string timeout such as "1h", which must be normalized before comparison rather
  1726. than bypassing the check.
  1727. """
  1728. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=3600)
  1729. with self.assertRaises(ValidationError):
  1730. webhook.full_clean()
  1731. webhook.timeout = 3599
  1732. webhook.full_clean()
  1733. @override_settings(RQ_DEFAULT_TIMEOUT='60')
  1734. def test_job_timeout_numeric_string_is_validated(self):
  1735. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=60)
  1736. with self.assertRaises(ValidationError):
  1737. webhook.full_clean()
  1738. webhook.timeout = 59
  1739. webhook.full_clean()
  1740. @override_settings(RQ_DEFAULT_TIMEOUT=-1)
  1741. def test_unbounded_job_timeout_skips_validation(self):
  1742. """
  1743. A negative RQ timeout (-1) disables RQ's death penalty, so there is no job timeout to validate against.
  1744. """
  1745. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=3600)
  1746. webhook.full_clean()
  1747. @override_settings(RQ_DEFAULT_TIMEOUT=0)
  1748. def test_zero_job_timeout_is_validated_against_queue_default(self):
  1749. """
  1750. A zero (or absent) RQ timeout is not unbounded: RQ falls back to the queue's own default, which the
  1751. webhook timeout must still stay below.
  1752. """
  1753. webhook = Webhook(name='Webhook 1', payload_url='http://localhost:9000/', timeout=Queue.DEFAULT_TIMEOUT)
  1754. with self.assertRaises(ValidationError):
  1755. webhook.full_clean()
  1756. webhook.timeout = Queue.DEFAULT_TIMEOUT - 1
  1757. webhook.full_clean()