test_utils.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. from types import SimpleNamespace
  2. from unittest.mock import patch
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.files.storage import Storage
  5. from django.test import TestCase
  6. from extras.models import ExportTemplate, ImageAttachment
  7. from extras.utils import _build_image_attachment_path, filename_from_model, image_upload
  8. from tenancy.models import ContactGroup, TenantGroup
  9. from wireless.models import WirelessLANGroup
  10. class FilenameFromModelTestCase(TestCase):
  11. def test_expected_output(self):
  12. cases = (
  13. (ExportTemplate, 'netbox_export_templates'),
  14. (ContactGroup, 'netbox_contact_groups'),
  15. (TenantGroup, 'netbox_tenant_groups'),
  16. (WirelessLANGroup, 'netbox_wireless_lan_groups'),
  17. )
  18. for model, expected in cases:
  19. self.assertEqual(filename_from_model(model), expected)
  20. class OverwriteStyleStorage(Storage):
  21. """
  22. Mimic an overwrite-style backend (for example, S3 with file_overwrite=True),
  23. where get_available_name() returns the incoming name unchanged.
  24. """
  25. def __init__(self, existing_names=None):
  26. self.existing_names = set(existing_names or [])
  27. def exists(self, name):
  28. return name in self.existing_names
  29. def get_available_name(self, name, max_length=None):
  30. return name
  31. def get_alternative_name(self, file_root, file_ext):
  32. return f'{file_root}_sdmmer4{file_ext}'
  33. class ImageUploadTestCase(TestCase):
  34. @classmethod
  35. def setUpTestData(cls):
  36. # We only need a ContentType with model="rack" for the prefix;
  37. # this doesn't require creating a Rack object.
  38. cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
  39. def _stub_instance(self, object_id=12, name=None):
  40. """
  41. Creates a minimal stub for use with image attachment path generation.
  42. """
  43. return SimpleNamespace(object_type=self.ct_rack, object_id=object_id, name=name)
  44. def _bound_instance(self, *, storage, object_id=12, name=None, max_length=100):
  45. return SimpleNamespace(
  46. object_type=self.ct_rack,
  47. object_id=object_id,
  48. name=name,
  49. image=SimpleNamespace(field=SimpleNamespace(storage=storage, max_length=max_length)),
  50. )
  51. def _second_segment(self, path: str):
  52. """
  53. Extracts and returns the portion of the input string after the
  54. first '/' character.
  55. """
  56. return path.split('/', 1)[1]
  57. def test_windows_fake_path_and_extension_lowercased(self):
  58. """
  59. Tests handling of a Windows file path with a fake directory and extension.
  60. """
  61. inst = self._stub_instance(name=None)
  62. path = _build_image_attachment_path(inst, r'C:\fake_path\MyPhoto.JPG')
  63. # Base directory and single-level path
  64. seg2 = self._second_segment(path)
  65. self.assertTrue(path.startswith('image-attachments/rack_12_'))
  66. self.assertNotIn('/', seg2, 'should not create nested directories')
  67. # Extension from the uploaded file, lowercased
  68. self.assertTrue(seg2.endswith('.jpg'))
  69. def test_name_with_slashes_is_flattened_no_subdirectories(self):
  70. """
  71. Tests that a name with slashes is flattened and does not
  72. create subdirectories.
  73. """
  74. inst = self._stub_instance(name='5/31/23')
  75. path = _build_image_attachment_path(inst, 'image.png')
  76. seg2 = self._second_segment(path)
  77. self.assertTrue(seg2.startswith('rack_12_'))
  78. self.assertNotIn('/', seg2)
  79. self.assertNotIn('\\', seg2)
  80. self.assertTrue(seg2.endswith('.png'))
  81. def test_name_with_backslashes_is_flattened_no_subdirectories(self):
  82. """
  83. Tests that a name including backslashes is correctly flattened
  84. into a single directory name without creating subdirectories.
  85. """
  86. inst = self._stub_instance(name=r'5\31\23')
  87. path = _build_image_attachment_path(inst, 'image_name.png')
  88. seg2 = self._second_segment(path)
  89. self.assertTrue(seg2.startswith('rack_12_'))
  90. self.assertNotIn('/', seg2)
  91. self.assertNotIn('\\', seg2)
  92. self.assertTrue(seg2.endswith('.png'))
  93. def test_prefix_format_is_as_expected(self):
  94. """
  95. Tests the output path format generated by the `image_upload` function.
  96. """
  97. inst = self._stub_instance(object_id=99, name='label')
  98. path = _build_image_attachment_path(inst, 'a.webp')
  99. # The second segment must begin with "rack_99_"
  100. seg2 = self._second_segment(path)
  101. self.assertTrue(seg2.startswith('rack_99_'))
  102. self.assertTrue(seg2.endswith('.webp'))
  103. def test_unsupported_file_extension(self):
  104. """
  105. Test that when the file extension is not allowed, the extension
  106. is omitted.
  107. """
  108. inst = self._stub_instance(name='test')
  109. path = _build_image_attachment_path(inst, 'document.txt')
  110. seg2 = self._second_segment(path)
  111. self.assertTrue(seg2.startswith('rack_12_test'))
  112. self.assertFalse(seg2.endswith('.txt'))
  113. # When not allowed, no extension should be appended
  114. self.assertNotRegex(seg2, r'\.txt$')
  115. def test_instance_name_with_whitespace_and_special_chars(self):
  116. """
  117. Test that an instance name with leading/trailing whitespace and
  118. special characters is sanitized properly.
  119. """
  120. # Suppose the instance name has surrounding whitespace and
  121. # extra slashes.
  122. inst = self._stub_instance(name=' my/complex\\name ')
  123. path = _build_image_attachment_path(inst, 'irrelevant.png')
  124. # The output should be flattened and sanitized.
  125. # We expect the name to be transformed into a valid filename without
  126. # path separators.
  127. seg2 = self._second_segment(path)
  128. self.assertNotIn(' ', seg2)
  129. self.assertNotIn('/', seg2)
  130. self.assertNotIn('\\', seg2)
  131. self.assertTrue(seg2.endswith('.png'))
  132. def test_separator_variants_with_subTest(self):
  133. """
  134. Tests that both forward slash and backslash in file paths are
  135. handled consistently by the `image_upload` function and
  136. processed into a sanitized uniform format.
  137. """
  138. for name in ['2025/09/12', r'2025\09\12']:
  139. with self.subTest(name=name):
  140. inst = self._stub_instance(name=name)
  141. path = _build_image_attachment_path(inst, 'x.jpeg')
  142. seg2 = self._second_segment(path)
  143. self.assertTrue(seg2.startswith('rack_12_'))
  144. self.assertNotIn('/', seg2)
  145. self.assertNotIn('\\', seg2)
  146. self.assertTrue(seg2.endswith('.jpeg') or seg2.endswith('.jpg'))
  147. def test_fallback_on_suspicious_file_operation(self):
  148. """
  149. Test that when default_storage.get_valid_name() raises a
  150. SuspiciousFileOperation, the fallback default is used.
  151. """
  152. inst = self._stub_instance(name=' ')
  153. path = _build_image_attachment_path(inst, 'sample.png')
  154. # Expect the fallback name 'unnamed' to be used.
  155. self.assertIn('unnamed', path)
  156. self.assertTrue(path.startswith('image-attachments/rack_12_'))
  157. def test_image_upload_preserves_original_name_when_available(self):
  158. inst = self._bound_instance(
  159. storage=OverwriteStyleStorage(),
  160. name='action-buttons',
  161. )
  162. path = image_upload(inst, 'action-buttons.png')
  163. self.assertEqual(path, 'image-attachments/rack_12_action-buttons.png')
  164. def test_image_upload_uses_base_collision_handling_with_overwrite_style_storage(self):
  165. inst = self._bound_instance(
  166. storage=OverwriteStyleStorage(existing_names={'image-attachments/rack_12_action-buttons.png'}),
  167. name='action-buttons',
  168. )
  169. path = image_upload(inst, 'action-buttons.png')
  170. self.assertEqual(
  171. path,
  172. 'image-attachments/rack_12_action-buttons_sdmmer4.png',
  173. )
  174. def test_image_field_generate_filename_uses_image_upload_collision_handling(self):
  175. field = ImageAttachment._meta.get_field('image')
  176. instance = ImageAttachment(
  177. object_type=self.ct_rack,
  178. object_id=12,
  179. )
  180. with patch.object(
  181. field,
  182. 'storage',
  183. OverwriteStyleStorage(existing_names={'image-attachments/rack_12_action-buttons.png'}),
  184. ):
  185. path = field.generate_filename(instance, 'action-buttons.png')
  186. self.assertEqual(
  187. path,
  188. 'image-attachments/rack_12_action-buttons_sdmmer4.png',
  189. )