test_scripts_deletion.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import uuid
  2. from unittest import mock
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.db import router
  5. from django.db.models import QuerySet
  6. from django.test import TestCase, override_settings
  7. from django.urls import reverse
  8. from core.choices import ManagedFileRootPathChoices
  9. from core.models import DataSource, Job
  10. from extras.models import Script, ScriptModule
  11. from extras.validators import CustomValidator
  12. from netbox.models.deletion import ConfirmCollector, CountOnly
  13. from utilities.exceptions import AbortRequest
  14. from utilities.testing import TestCase as ViewTestCase
  15. class ScriptDeletionTestCase(TestCase):
  16. """
  17. Regression tests for #22812: deleting a JobsMixin object (Script, ScriptModule, DataSource)
  18. with many associated Jobs must not load every Job into memory at once.
  19. """
  20. @classmethod
  21. def setUpTestData(cls):
  22. cls.script_ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
  23. def _create_module(self):
  24. return ScriptModule.objects.create(
  25. file_root=ManagedFileRootPathChoices.SCRIPTS,
  26. file_path=f'test_{uuid.uuid4().hex[:8]}.py',
  27. )
  28. def _create_script(self, module=None):
  29. module = module or self._create_module()
  30. script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
  31. return module, script
  32. def _add_jobs(self, obj, count, object_type=None):
  33. object_type = object_type or ContentType.objects.get_for_model(type(obj), for_concrete_model=False)
  34. Job.objects.bulk_create([
  35. Job(
  36. object_type=object_type,
  37. object_id=obj.pk,
  38. name='testjob',
  39. status='completed',
  40. job_id=uuid.uuid4(),
  41. data={'output': 'x' * 50},
  42. )
  43. for _ in range(count)
  44. ])
  45. def test_delete_script_deletes_all_jobs(self):
  46. _, script = self._create_script()
  47. self._add_jobs(script, 2500)
  48. self.assertEqual(script.jobs.count(), 2500)
  49. script.delete()
  50. self.assertFalse(Script.objects.filter(pk=script.pk).exists())
  51. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
  52. def test_delete_script_batches_jobs(self):
  53. _, script = self._create_script()
  54. self._add_jobs(script, 5)
  55. job_delete_calls = []
  56. original_delete = QuerySet.delete
  57. def counting_delete(qs, *args, **kwargs):
  58. if qs.model is Job:
  59. job_delete_calls.append(len(qs))
  60. return original_delete(qs, *args, **kwargs)
  61. with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
  62. with mock.patch.object(QuerySet, 'delete', counting_delete):
  63. script.delete()
  64. # 5 jobs at a batch size of 2 => three batched deletes (2, 2, 1)
  65. self.assertEqual(job_delete_calls, [2, 2, 1])
  66. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
  67. def test_delete_scriptmodule_cascades_to_scripts_and_jobs(self):
  68. module, script = self._create_script()
  69. self._add_jobs(script, 100)
  70. module.delete()
  71. self.assertFalse(ScriptModule.objects.filter(pk=module.pk).exists())
  72. self.assertFalse(Script.objects.filter(pk=script.pk).exists())
  73. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
  74. def test_delete_scriptmodule_batches_child_script_jobs(self):
  75. # The reporter's actual path: a script is only removable via the UI by deleting its
  76. # ScriptModule. The module's delete must batch the child Script's jobs.
  77. module, script = self._create_script()
  78. self._add_jobs(script, 5)
  79. job_delete_calls = []
  80. original_delete = QuerySet.delete
  81. def counting_delete(qs, *args, **kwargs):
  82. if qs.model is Job:
  83. job_delete_calls.append(len(qs))
  84. return original_delete(qs, *args, **kwargs)
  85. with mock.patch('netbox.models.features.JOB_DELETE_BATCH_SIZE', 2):
  86. with mock.patch.object(QuerySet, 'delete', counting_delete):
  87. module.delete()
  88. # 5 child-script jobs at a batch size of 2 => three batched deletes (2, 2, 1). The module
  89. # has no jobs of its own, so JobsMixin.delete adds no further Job deletes.
  90. self.assertEqual(job_delete_calls, [2, 2, 1])
  91. self.assertFalse(Script.objects.filter(pk=script.pk).exists())
  92. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 0)
  93. def test_delete_datasource_deletes_jobs(self):
  94. datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
  95. self._add_jobs(datasource, 100)
  96. ds_ct = ContentType.objects.get_for_model(DataSource, for_concrete_model=False)
  97. self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 100)
  98. datasource.delete()
  99. self.assertFalse(DataSource.objects.filter(pk=datasource.pk).exists())
  100. self.assertEqual(Job.objects.filter(object_type=ds_ct, object_id=datasource.pk).count(), 0)
  101. def test_soft_delete_preserves_jobs(self):
  102. _, script = self._create_script()
  103. self._add_jobs(script, 10)
  104. script.delete(soft_delete=True)
  105. script.refresh_from_db()
  106. self.assertFalse(script.is_executable)
  107. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
  108. @override_settings(PROTECTION_RULES={'extras.script': [CustomValidator({'name': {'eq': ''}})]})
  109. def test_delete_rolls_back_jobs_on_parent_failure(self):
  110. # A protection rule that no real script can satisfy (name must be empty) makes the
  111. # cascade's pre_delete handler raise AbortRequest *after* JobsMixin.delete has already
  112. # batch-deleted the jobs. JobsMixin.delete wraps the batch loop and super().delete() in a
  113. # transaction, so the job deletions must roll back, leaving no orphaned partial state.
  114. # This exercises the real deletion-abort path rather than mocking Django internals.
  115. _, script = self._create_script()
  116. self._add_jobs(script, 10)
  117. with self.assertRaises(AbortRequest):
  118. script.delete()
  119. self.assertTrue(Script.objects.filter(pk=script.pk).exists())
  120. self.assertEqual(Job.objects.filter(object_type=self.script_ct, object_id=script.pk).count(), 10)
  121. class ConfirmCollectorTestCase(TestCase):
  122. """
  123. #22812: the delete-confirmation page must not materialize every dependent Job.
  124. """
  125. def _create_script_with_jobs(self, count):
  126. module = ScriptModule.objects.create(
  127. file_root=ManagedFileRootPathChoices.SCRIPTS,
  128. file_path=f'test_{uuid.uuid4().hex[:8]}.py',
  129. )
  130. script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
  131. ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
  132. Job.objects.bulk_create([
  133. Job(object_type=ct, object_id=script.pk, name='j', status='completed',
  134. job_id=uuid.uuid4(), data={'output': 'x' * 50})
  135. for _ in range(count)
  136. ])
  137. return script
  138. def test_confirm_collector_counts_jobs_without_instantiating(self):
  139. script = self._create_script_with_jobs(500)
  140. init_calls = []
  141. original_init = Job.__init__
  142. def counting_init(self, *args, **kwargs):
  143. init_calls.append(1)
  144. original_init(self, *args, **kwargs)
  145. with mock.patch.object(Job, '__init__', counting_init):
  146. collector = ConfirmCollector(using=router.db_for_write(Script))
  147. collector.collect([script])
  148. # No Job rows were instantiated; the relation was counted instead.
  149. self.assertEqual(len(init_calls), 0)
  150. self.assertNotIn(Job, collector.data)
  151. self.assertEqual(collector.generic_relation_counts.get(Job), 500)
  152. # The non-job cascade (the Script itself) is still collected.
  153. self.assertIn(Script, collector.data)
  154. def test_count_only_wrapper(self):
  155. # CountOnly reports its count via len() but iterates empty, so it slots into the
  156. # dependent-objects mapping as a non-expandable, non-materializing row.
  157. wrapper = CountOnly(3000)
  158. self.assertEqual(len(wrapper), 3000)
  159. self.assertEqual(list(wrapper), [])
  160. self.assertTrue(wrapper.count_only)
  161. def test_confirm_collector_omits_jobs_when_none(self):
  162. # A jobless object must not record a zero count, or the confirmation page would show a
  163. # spurious "0 jobs" row (#22812 regression).
  164. datasource = DataSource.objects.create(name='DS', type='local', source_url='/tmp/test')
  165. collector = ConfirmCollector(using=router.db_for_write(DataSource))
  166. collector.collect([datasource])
  167. self.assertNotIn(Job, collector.generic_relation_counts)
  168. class ObjectDeleteViewCountsTestCase(ViewTestCase):
  169. """
  170. #22812: the delete-confirmation view must report a JobsMixin object's jobs as a count
  171. (via CountOnly) without materializing them, and _get_dependent_objects must keep returning
  172. a single dict.
  173. """
  174. def test_get_dependent_objects_returns_count_only_for_jobs(self):
  175. from netbox.views.generic.object_views import ObjectDeleteView
  176. module = ScriptModule.objects.create(
  177. file_root=ManagedFileRootPathChoices.SCRIPTS,
  178. file_path=f'test_{uuid.uuid4().hex[:8]}.py',
  179. )
  180. script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
  181. ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
  182. Job.objects.bulk_create([
  183. Job(object_type=ct, object_id=script.pk, name='j', status='completed', job_id=uuid.uuid4())
  184. for _ in range(50)
  185. ])
  186. view = ObjectDeleteView()
  187. view.queryset = ScriptModule.objects.all()
  188. dependent_objects = view._get_dependent_objects(module)
  189. # Single dict returned (not a tuple); jobs represented as a CountOnly.
  190. self.assertIsInstance(dependent_objects, dict)
  191. self.assertIn(Job, dependent_objects)
  192. self.assertIsInstance(dependent_objects[Job], CountOnly)
  193. self.assertEqual(len(dependent_objects[Job]), 50)
  194. @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
  195. def test_confirm_page_renders_job_count(self):
  196. module = ScriptModule.objects.create(
  197. file_root=ManagedFileRootPathChoices.SCRIPTS,
  198. file_path=f'test_{uuid.uuid4().hex[:8]}.py',
  199. )
  200. script = Script.objects.create(module=module, name=f'S{uuid.uuid4().hex[:8]}')
  201. ct = ContentType.objects.get_for_model(Script, for_concrete_model=False)
  202. Job.objects.bulk_create([
  203. Job(object_type=ct, object_id=script.pk, name='j', status='completed', job_id=uuid.uuid4())
  204. for _ in range(50)
  205. ])
  206. # ScriptModule is a proxy over core.ManagedFile, so the delete view requires the
  207. # concrete model's permission (core.delete_managedfile), not extras.delete_scriptmodule.
  208. self.add_permissions('core.delete_managedfile')
  209. url = reverse('extras:scriptmodule_delete', kwargs={'pk': module.pk})
  210. response = self.client.get(url)
  211. self.assertEqual(response.status_code, 200)
  212. # Assert on the rendered context, not brittle HTML substrings: Job is present in
  213. # dependent_objects as a CountOnly reporting the true count, so the confirmation page
  214. # renders it as a summarized (non-expandable) row without materializing 50 Job rows.
  215. dependent_objects = response.context['dependent_objects']
  216. self.assertIn(Job, dependent_objects)
  217. self.assertIsInstance(dependent_objects[Job], CountOnly)
  218. self.assertEqual(len(dependent_objects[Job]), 50)
  219. self.assertTrue(dependent_objects[Job].count_only)
  220. def test_get_dependent_objects_omits_jobs_when_none(self):
  221. from netbox.views.generic.object_views import ObjectDeleteView
  222. # A module with no jobs must not produce a CountOnly(0) entry (#22812 regression).
  223. module = ScriptModule.objects.create(
  224. file_root=ManagedFileRootPathChoices.SCRIPTS,
  225. file_path=f'test_{uuid.uuid4().hex[:8]}.py',
  226. )
  227. view = ObjectDeleteView()
  228. view.queryset = ScriptModule.objects.all()
  229. dependent_objects = view._get_dependent_objects(module)
  230. self.assertNotIn(Job, dependent_objects)