test_views.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. import json
  2. import urllib.parse
  3. import uuid
  4. from datetime import datetime
  5. from unittest.mock import patch
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.core.cache import cache
  8. from django.urls import reverse
  9. from django.utils import timezone
  10. from django_rq import get_queue
  11. from django_rq.settings import get_queues_map
  12. from django_rq.workers import get_worker
  13. from rq.job import Job as RQ_Job
  14. from rq.job import JobStatus
  15. from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
  16. from core.choices import CorePluginStatusChoices, ObjectChangeActionChoices
  17. from core.core_plugins import CORE_PLUGINS, get_core_plugins
  18. from core.models import *
  19. from core.plugins import Plugin
  20. from dcim.models import Site
  21. from users.models import User
  22. from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
  23. class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
  24. model = DataSource
  25. @classmethod
  26. def setUpTestData(cls):
  27. data_sources = (
  28. DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
  29. DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
  30. DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
  31. )
  32. DataSource.objects.bulk_create(data_sources)
  33. tags = create_tags('Alpha', 'Bravo', 'Charlie')
  34. cls.form_data = {
  35. 'name': 'Data Source X',
  36. 'type': 'git',
  37. 'source_url': 'http:///exmaple/com/foo/bar/',
  38. 'description': 'Something',
  39. 'comments': 'Foo bar baz',
  40. 'tags': [t.pk for t in tags],
  41. }
  42. cls.csv_data = (
  43. "name,type,source_url,enabled",
  44. "Data Source 4,local,file:///var/tmp/source4/,true",
  45. "Data Source 5,local,file:///var/tmp/source4/,true",
  46. "Data Source 6,git,http:///exmaple/com/foo/bar/,false",
  47. )
  48. cls.csv_update_data = (
  49. "id,name,description",
  50. f"{data_sources[0].pk},Data Source 7,New description7",
  51. f"{data_sources[1].pk},Data Source 8,New description8",
  52. f"{data_sources[2].pk},Data Source 9,New description9",
  53. )
  54. cls.bulk_edit_data = {
  55. 'enabled': False,
  56. 'description': 'New description',
  57. }
  58. class DataFileTestCase(
  59. ViewTestCases.GetObjectViewTestCase,
  60. ViewTestCases.DeleteObjectViewTestCase,
  61. ViewTestCases.ListObjectsViewTestCase,
  62. ViewTestCases.BulkDeleteObjectsViewTestCase,
  63. ):
  64. model = DataFile
  65. @classmethod
  66. def setUpTestData(cls):
  67. datasource = DataSource.objects.create(
  68. name='Data Source 1',
  69. type='local',
  70. source_url='file:///var/tmp/source1/'
  71. )
  72. data_files = (
  73. DataFile(
  74. source=datasource,
  75. path='dir1/file1.txt',
  76. last_updated=timezone.now(),
  77. size=1000,
  78. hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
  79. ),
  80. DataFile(
  81. source=datasource,
  82. path='dir1/file2.txt',
  83. last_updated=timezone.now(),
  84. size=2000,
  85. hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
  86. ),
  87. DataFile(
  88. source=datasource,
  89. path='dir1/file3.txt',
  90. last_updated=timezone.now(),
  91. size=3000,
  92. hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
  93. ),
  94. )
  95. DataFile.objects.bulk_create(data_files)
  96. class JobTestCase(
  97. ViewTestCases.GetObjectViewTestCase,
  98. ViewTestCases.ListObjectsViewTestCase,
  99. ViewTestCases.DeleteObjectViewTestCase,
  100. ViewTestCases.BulkDeleteObjectsViewTestCase,
  101. ):
  102. model = Job
  103. @classmethod
  104. def setUpTestData(cls):
  105. datasource = DataSource.objects.create(
  106. name='Data Source 1',
  107. type='local',
  108. source_url='file:///var/tmp/source1/',
  109. )
  110. ct = ContentType.objects.get_for_model(DataSource)
  111. Job.objects.bulk_create(
  112. [
  113. Job(
  114. name='Job 1',
  115. object_type=ct,
  116. object_id=datasource.pk,
  117. status='pending',
  118. queue_name='default',
  119. job_id=uuid.uuid4(),
  120. ),
  121. Job(
  122. name='Job 2',
  123. object_type=ct,
  124. object_id=datasource.pk,
  125. status='running',
  126. queue_name='default',
  127. job_id=uuid.uuid4(),
  128. ),
  129. Job(
  130. name='Job 3',
  131. object_type=ct,
  132. object_id=datasource.pk,
  133. status='completed',
  134. queue_name='default',
  135. job_id=uuid.uuid4(),
  136. ),
  137. ]
  138. )
  139. # TODO: Convert to StandardTestCases.Views
  140. class ObjectChangeTestCase(TestCase):
  141. user_permissions = (
  142. 'core.view_objectchange',
  143. )
  144. @classmethod
  145. def setUpTestData(cls):
  146. site = Site(name='Site 1', slug='site-1')
  147. site.save()
  148. # Create three ObjectChanges
  149. user = User.objects.create_user(username='testuser2')
  150. for i in range(1, 4):
  151. oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
  152. oc.user = user
  153. oc.request_id = uuid.uuid4()
  154. oc.save()
  155. def test_objectchange_list(self):
  156. url = reverse('core:objectchange_list')
  157. params = {
  158. "user": User.objects.first().pk,
  159. }
  160. response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
  161. self.assertHttpStatus(response, 200)
  162. def test_objectchange(self):
  163. objectchange = ObjectChange.objects.first()
  164. response = self.client.get(objectchange.get_absolute_url())
  165. self.assertHttpStatus(response, 200)
  166. class BackgroundTaskTestCase(TestCase):
  167. user_permissions = ()
  168. # Dummy worker functions
  169. @staticmethod
  170. def dummy_job_default():
  171. return "Job finished"
  172. @staticmethod
  173. def dummy_job_high():
  174. return "Job finished"
  175. @staticmethod
  176. def dummy_job_failing():
  177. raise Exception("Job failed")
  178. def setUp(self):
  179. super().setUp()
  180. self.user.is_superuser = True
  181. self.user.is_active = True
  182. self.user.save()
  183. # Clear all queues prior to running each test
  184. get_queue('default').connection.flushall()
  185. get_queue('high').connection.flushall()
  186. get_queue('low').connection.flushall()
  187. def tearDown(self):
  188. super().tearDown()
  189. # Clear all queues after each test so no leftover jobs leak into the next test suite
  190. get_queue('default').connection.flushall()
  191. get_queue('high').connection.flushall()
  192. get_queue('low').connection.flushall()
  193. def test_background_queue_list(self):
  194. url = reverse('core:background_queue_list')
  195. # Attempt to load view without permission
  196. self.user.is_superuser = False
  197. self.user.save()
  198. response = self.client.get(url)
  199. self.assertEqual(response.status_code, 403)
  200. # Load view with permission
  201. self.user.is_superuser = True
  202. self.user.save()
  203. response = self.client.get(url)
  204. self.assertEqual(response.status_code, 200)
  205. self.assertIn('default', str(response.content))
  206. self.assertIn('high', str(response.content))
  207. self.assertIn('low', str(response.content))
  208. def test_background_tasks_list_default(self):
  209. queue = get_queue('default')
  210. queue.enqueue(self.dummy_job_default)
  211. queue_index = get_queues_map()['default']
  212. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
  213. self.assertEqual(response.status_code, 200)
  214. self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content))
  215. def test_background_tasks_list_high(self):
  216. queue = get_queue('high')
  217. queue.enqueue(self.dummy_job_high)
  218. queue_index = get_queues_map()['high']
  219. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'queued']))
  220. self.assertEqual(response.status_code, 200)
  221. self.assertIn('BackgroundTaskTestCase.dummy_job_high', str(response.content))
  222. def test_background_tasks_list_finished(self):
  223. queue = get_queue('default')
  224. job = queue.enqueue(self.dummy_job_default)
  225. queue_index = get_queues_map()['default']
  226. registry = FinishedJobRegistry(queue.name, queue.connection)
  227. registry.add(job, 2)
  228. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'finished']))
  229. self.assertEqual(response.status_code, 200)
  230. self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content))
  231. def test_background_tasks_list_failed(self):
  232. queue = get_queue('default')
  233. job = queue.enqueue(self.dummy_job_default)
  234. queue_index = get_queues_map()['default']
  235. registry = FailedJobRegistry(queue.name, queue.connection)
  236. registry.add(job, 2)
  237. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'failed']))
  238. self.assertEqual(response.status_code, 200)
  239. self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content))
  240. def test_background_tasks_scheduled(self):
  241. queue = get_queue('default')
  242. queue.enqueue_at(datetime.now(), self.dummy_job_default)
  243. queue_index = get_queues_map()['default']
  244. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'scheduled']))
  245. self.assertEqual(response.status_code, 200)
  246. self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content))
  247. def test_background_tasks_list_deferred(self):
  248. queue = get_queue('default')
  249. job = queue.enqueue(self.dummy_job_default)
  250. queue_index = get_queues_map()['default']
  251. registry = DeferredJobRegistry(queue.name, queue.connection)
  252. registry.add(job, 2)
  253. response = self.client.get(reverse('core:background_task_list', args=[queue_index, 'deferred']))
  254. self.assertEqual(response.status_code, 200)
  255. self.assertIn('BackgroundTaskTestCase.dummy_job_default', str(response.content))
  256. def test_background_task(self):
  257. queue = get_queue('default')
  258. job = queue.enqueue(self.dummy_job_default)
  259. response = self.client.get(reverse('core:background_task', args=[job.id]))
  260. self.assertEqual(response.status_code, 200)
  261. self.assertIn('Background Tasks', str(response.content))
  262. self.assertIn(str(job.id), str(response.content))
  263. self.assertIn('Callable', str(response.content))
  264. self.assertIn('Meta', str(response.content))
  265. self.assertIn('Keyword Arguments', str(response.content))
  266. def test_background_task_delete(self):
  267. queue = get_queue('default')
  268. job = queue.enqueue(self.dummy_job_default)
  269. response = self.client.post(reverse('core:background_task_delete', args=[job.id]), {'confirm': True})
  270. self.assertEqual(response.status_code, 302)
  271. self.assertFalse(RQ_Job.exists(job.id, connection=queue.connection))
  272. self.assertNotIn(job.id, queue.job_ids)
  273. def test_background_task_requeue(self):
  274. queue = get_queue('default')
  275. # Enqueue & run a job that will fail
  276. job = queue.enqueue(self.dummy_job_failing)
  277. worker = get_worker('default')
  278. with disable_logging():
  279. worker.work(burst=True)
  280. self.assertTrue(job.is_failed)
  281. # Re-enqueue the failed job and check that its status has been reset
  282. response = self.client.get(reverse('core:background_task_requeue', args=[job.id]))
  283. self.assertEqual(response.status_code, 302)
  284. self.assertFalse(job.is_failed)
  285. def test_background_task_enqueue(self):
  286. queue = get_queue('default')
  287. # Enqueue some jobs that each depends on its predecessor
  288. job = previous_job = None
  289. for _ in range(0, 3):
  290. job = queue.enqueue(self.dummy_job_default, depends_on=previous_job)
  291. previous_job = job
  292. # Check that the last job to be enqueued has a status of deferred
  293. self.assertIsNotNone(job)
  294. self.assertEqual(job.get_status(), JobStatus.DEFERRED)
  295. self.assertIsNone(job.enqueued_at)
  296. # Force-enqueue the deferred job
  297. response = self.client.get(reverse('core:background_task_enqueue', args=[job.id]))
  298. self.assertEqual(response.status_code, 302)
  299. # Check that job's status is updated correctly
  300. job = queue.fetch_job(job.id)
  301. self.assertEqual(job.get_status(), JobStatus.QUEUED)
  302. self.assertIsNotNone(job.enqueued_at)
  303. def test_background_task_stop(self):
  304. queue = get_queue('default')
  305. worker = get_worker('default')
  306. job = queue.enqueue(self.dummy_job_default)
  307. worker.prepare_job_execution(job)
  308. worker.prepare_execution(job)
  309. self.assertEqual(job.get_status(), JobStatus.STARTED)
  310. # Stop those jobs using the view
  311. started_job_registry = StartedJobRegistry(queue.name, connection=queue.connection)
  312. self.assertEqual(len(started_job_registry), 1)
  313. response = self.client.get(reverse('core:background_task_stop', args=[job.id]))
  314. self.assertEqual(response.status_code, 302)
  315. with disable_logging():
  316. worker.monitor_work_horse(job, queue) # Sets the job as Failed and removes from Started
  317. self.assertEqual(len(started_job_registry), 0)
  318. canceled_job_registry = FailedJobRegistry(queue.name, connection=queue.connection)
  319. self.assertEqual(len(canceled_job_registry), 1)
  320. self.assertIn(job.id, canceled_job_registry)
  321. def test_worker_list(self):
  322. worker1 = get_worker('default', name=uuid.uuid4().hex)
  323. worker1.register_birth()
  324. worker2 = get_worker('high')
  325. worker2.register_birth()
  326. queue_index = get_queues_map()['default']
  327. response = self.client.get(reverse('core:worker_list', args=[queue_index]))
  328. self.assertEqual(response.status_code, 200)
  329. self.assertIn(str(worker1.name), str(response.content))
  330. self.assertNotIn(str(worker2.name), str(response.content))
  331. def test_worker(self):
  332. worker1 = get_worker('default', name=uuid.uuid4().hex)
  333. worker1.register_birth()
  334. response = self.client.get(reverse('core:worker', args=[worker1.name]))
  335. self.assertEqual(response.status_code, 200)
  336. self.assertIn(str(worker1.name), str(response.content))
  337. self.assertIn('Birth', str(response.content))
  338. self.assertIn('Total working time', str(response.content))
  339. class SystemTestCase(TestCase):
  340. def setUp(self):
  341. super().setUp()
  342. self.user.is_superuser = True
  343. self.user.save()
  344. def test_system_view_default(self):
  345. # Test UI render
  346. response = self.client.get(reverse('core:system'))
  347. self.assertEqual(response.status_code, 200)
  348. # Test export
  349. response = self.client.get(f"{reverse('core:system')}?export=true")
  350. self.assertEqual(response.status_code, 200)
  351. data = json.loads(response.content)
  352. self.assertIn('netbox_release', data)
  353. self.assertIn('plugins', data)
  354. self.assertIn('config', data)
  355. self.assertIn('objects', data)
  356. self.assertIn('db_schema', data)
  357. def test_system_view_with_config_revision(self):
  358. ConfigRevision.objects.create()
  359. # Test UI render
  360. response = self.client.get(reverse('core:system'))
  361. self.assertEqual(response.status_code, 200)
  362. # Test export
  363. response = self.client.get(f"{reverse('core:system')}?export=true")
  364. self.assertEqual(response.status_code, 200)
  365. class PluginListViewTestCase(TestCase):
  366. """
  367. Tests for the Core Plugins section rendered on the plugins list page.
  368. """
  369. def setUp(self):
  370. super().setUp()
  371. self.user.is_superuser = True
  372. self.user.save()
  373. # The plugin catalog feed is cached process-wide; clear it so the patched
  374. # catalog fetch is honored on every request.
  375. cache.delete('plugins-catalog-feed')
  376. cache.delete('plugins-catalog-error')
  377. def _set_commercial_features(self, enabled):
  378. # settings.RELEASE is a dataclass loaded at startup; mutate the field in
  379. # place and restore on teardown.
  380. from django.conf import settings
  381. original = settings.RELEASE.features.commercial
  382. settings.RELEASE.features.commercial = enabled
  383. self.addCleanup(setattr, settings.RELEASE.features, 'commercial', original)
  384. @patch('core.views.get_catalog_plugins', return_value={})
  385. def test_plugin_list_shows_core_section_locked_for_oss(self, _mock_catalog):
  386. self._set_commercial_features(False)
  387. response = self.client.get(reverse('core:plugin_list'))
  388. self.assertEqual(response.status_code, 200)
  389. core_plugins = response.context['core_plugins']
  390. self.assertEqual(len(core_plugins), len(CORE_PLUGINS))
  391. # Commercial plugins should be Locked in OSS; non-commercial plugins should
  392. # appear as Available.
  393. status_by_name = {entry['config_name']: entry['status'] for entry in core_plugins}
  394. for plugin in CORE_PLUGINS:
  395. expected = (
  396. CorePluginStatusChoices.STATUS_LOCKED if plugin.commercial
  397. else CorePluginStatusChoices.STATUS_AVAILABLE
  398. )
  399. self.assertEqual(status_by_name[plugin.config_name], expected)
  400. # The Core Plugins heading and at least one product link should render.
  401. self.assertContains(response, 'Core Plugins')
  402. first_commercial = next(p for p in CORE_PLUGINS if p.commercial)
  403. self.assertContains(response, first_commercial.product_url)
  404. @patch('core.views.get_catalog_plugins', return_value={})
  405. def test_plugin_list_shows_core_section_available_for_commercial(self, _mock_catalog):
  406. self._set_commercial_features(True)
  407. response = self.client.get(reverse('core:plugin_list'))
  408. self.assertEqual(response.status_code, 200)
  409. for entry in response.context['core_plugins']:
  410. self.assertEqual(entry['status'], CorePluginStatusChoices.STATUS_AVAILABLE)
  411. def test_get_core_plugins_marks_installed(self):
  412. # Unit-level test: simulate a locally-installed core plugin and confirm
  413. # the helper reports it as installed with the recorded version.
  414. target = CORE_PLUGINS[0]
  415. local_plugins = {
  416. target.config_name: Plugin(
  417. config_name=target.config_name,
  418. title_short=str(target.title),
  419. title_long=str(target.title),
  420. is_local=True,
  421. is_loaded=True,
  422. installed_version='1.2.3',
  423. ),
  424. }
  425. self._set_commercial_features(False)
  426. entries = get_core_plugins(local_plugins)
  427. installed = next(e for e in entries if e['config_name'] == target.config_name)
  428. self.assertEqual(installed['status'], CorePluginStatusChoices.STATUS_INSTALLED)
  429. self.assertEqual(installed['installed_version'], '1.2.3')
  430. @patch('core.views.get_catalog_plugins')
  431. def test_plugin_list_excludes_core_from_community_list(self, mock_catalog):
  432. # Seed the "catalog" with one of the core plugins plus a community plugin,
  433. # and verify only the community plugin reaches the catalog table.
  434. target = CORE_PLUGINS[0]
  435. mock_catalog.return_value = {
  436. target.config_name: Plugin(
  437. config_name=target.config_name,
  438. title_short=str(target.title),
  439. title_long=str(target.title),
  440. ),
  441. 'some_community_plugin': Plugin(
  442. config_name='some_community_plugin',
  443. title_short='Community Plugin',
  444. title_long='Community Plugin',
  445. ),
  446. }
  447. self._set_commercial_features(False)
  448. response = self.client.get(reverse('core:plugin_list'))
  449. self.assertEqual(response.status_code, 200)
  450. table_rows = list(response.context['table'].rows)
  451. row_names = {row.record.config_name for row in table_rows}
  452. self.assertIn('some_community_plugin', row_names)
  453. self.assertNotIn(target.config_name, row_names)