test_models.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import tempfile
  2. import uuid
  3. from contextlib import contextmanager
  4. from datetime import timedelta
  5. from io import StringIO
  6. from pathlib import Path
  7. from unittest.mock import MagicMock, patch
  8. from django.contrib.contenttypes.models import ContentType
  9. from django.core.exceptions import ObjectDoesNotExist
  10. from django.core.management import call_command
  11. from django.db import IntegrityError, connection
  12. from django.test import TestCase, override_settings
  13. from django.test.utils import CaptureQueriesContext
  14. from django.urls import reverse
  15. from django.utils import timezone
  16. from core.choices import DataSourceStatusChoices, JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
  17. from core.data_backends import LocalBackend
  18. from core.exceptions import SyncError
  19. from core.models import AutoSyncRecord, DataFile, DataSource, Job, ObjectType
  20. from core.signals import post_sync
  21. from dcim.models import Device, Location, Site
  22. from extras.models import ConfigTemplate, Notification, Script
  23. from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
  24. from users.models import User
  25. from utilities.querysets import RestrictedQuerySet
  26. from utilities.testing import PinnedConnectionRouter
  27. class DataSourceTestCase(TestCase):
  28. def setUp(self):
  29. # A synced local source, used by the sync tests
  30. temp_dir = tempfile.TemporaryDirectory()
  31. self.addCleanup(temp_dir.cleanup)
  32. self.root = Path(temp_dir.name)
  33. (self.root / 'a.txt').write_text('old\n')
  34. (self.root / 'obsolete.txt').write_text('old\n')
  35. self.datasource = DataSource.objects.create(name='Data Source 2', type='local', source_url=self.root.as_uri())
  36. self.datasource.sync()
  37. self.original_files = list(self.datasource.datafiles.values())
  38. self.last_synced = self.datasource.last_synced
  39. # The next sync finds one file changed, one removed and one added
  40. (self.root / 'a.txt').write_text('new\n')
  41. (self.root / 'obsolete.txt').unlink()
  42. (self.root / 'b.txt').write_text('new\n')
  43. def assert_files_unchanged(self):
  44. self.assertEqual(list(self.datasource.datafiles.values()), self.original_files)
  45. self.datasource.refresh_from_db()
  46. self.assertEqual(self.datasource.last_synced, self.last_synced)
  47. def assert_files_synced(self):
  48. self.assertEqual(
  49. dict(self.datasource.datafiles.values_list('path', 'data')),
  50. {'a.txt': b'new\n', 'b.txt': b'new\n'},
  51. )
  52. self.datasource.refresh_from_db()
  53. self.assertEqual(self.datasource.status, DataSourceStatusChoices.COMPLETED)
  54. self.assertGreater(self.datasource.last_synced, self.last_synced)
  55. def test_no_ignore_rules(self):
  56. ds = DataSource(ignore_rules='')
  57. self.assertFalse(ds._ignore('README.md'))
  58. self.assertFalse(ds._ignore('subdir/file.py'))
  59. def test_ignore_by_filename(self):
  60. ds = DataSource(ignore_rules='*.txt')
  61. self.assertTrue(ds._ignore('notes.txt'))
  62. self.assertTrue(ds._ignore('subdir/notes.txt'))
  63. self.assertFalse(ds._ignore('notes.py'))
  64. def test_ignore_by_subdirectory(self):
  65. ds = DataSource(ignore_rules='dev/*')
  66. self.assertTrue(ds._ignore('dev/README.md'))
  67. self.assertTrue(ds._ignore('dev/script.py'))
  68. self.assertFalse(ds._ignore('prod/script.py'))
  69. def test_password_added_on_create(self):
  70. datasource = DataSource.objects.create(
  71. name='Data Source 1',
  72. type='git',
  73. source_url='http://localhost/',
  74. parameters={
  75. 'username': 'jeff',
  76. 'password': 'foobar123',
  77. }
  78. )
  79. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_CREATE)
  80. self.assertIsNone(objectchange.prechange_data)
  81. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  82. self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
  83. def test_password_added_on_update(self):
  84. datasource = DataSource.objects.create(
  85. name='Data Source 1',
  86. type='git',
  87. source_url='http://localhost/'
  88. )
  89. datasource.snapshot()
  90. # Add a blank password
  91. datasource.parameters = {
  92. 'username': 'jeff',
  93. 'password': '',
  94. }
  95. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  96. self.assertIsNone(objectchange.prechange_data['parameters'])
  97. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  98. self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
  99. # Add a password
  100. datasource.parameters = {
  101. 'username': 'jeff',
  102. 'password': 'foobar123',
  103. }
  104. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  105. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  106. self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
  107. def test_password_changed(self):
  108. datasource = DataSource.objects.create(
  109. name='Data Source 1',
  110. type='git',
  111. source_url='http://localhost/',
  112. parameters={
  113. 'username': 'jeff',
  114. 'password': 'password1',
  115. }
  116. )
  117. datasource.snapshot()
  118. # Change the password
  119. datasource.parameters['password'] = 'password2'
  120. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  121. self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
  122. self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
  123. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  124. self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
  125. def test_password_removed_on_update(self):
  126. datasource = DataSource.objects.create(
  127. name='Data Source 1',
  128. type='git',
  129. source_url='http://localhost/',
  130. parameters={
  131. 'username': 'jeff',
  132. 'password': 'foobar123',
  133. }
  134. )
  135. datasource.snapshot()
  136. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  137. self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
  138. self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
  139. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  140. self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
  141. # Remove the password
  142. datasource.parameters['password'] = ''
  143. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  144. self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
  145. self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
  146. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
  147. self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
  148. def test_password_not_modified(self):
  149. datasource = DataSource.objects.create(
  150. name='Data Source 1',
  151. type='git',
  152. source_url='http://localhost/',
  153. parameters={
  154. 'username': 'username1',
  155. 'password': 'foobar123',
  156. }
  157. )
  158. datasource.snapshot()
  159. # Remove the password
  160. datasource.parameters['username'] = 'username2'
  161. objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
  162. self.assertEqual(objectchange.prechange_data['parameters']['username'], 'username1')
  163. self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
  164. self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
  165. self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
  166. def test_sync_rolls_back_read_failure(self):
  167. """A sync that fails reading a new file keeps the synced files and their links."""
  168. obsolete = self.datasource.datafiles.get(path='obsolete.txt')
  169. template = ConfigTemplate.objects.create(
  170. name='Config Template 1',
  171. data_source=self.datasource,
  172. data_file=obsolete,
  173. data_path=obsolete.path,
  174. template_code='old\n',
  175. auto_sync_enabled=True,
  176. )
  177. autosync = AutoSyncRecord.objects.get(datafile=obsolete)
  178. (self.root / 'link.txt').symlink_to('missing.txt')
  179. with patch.object(post_sync, 'send') as send:
  180. with self.assertRaises(FileNotFoundError):
  181. call_command('syncdatasource', self.datasource.name, stdout=StringIO())
  182. self.assert_files_unchanged()
  183. self.assertEqual(self.datasource.status, DataSourceStatusChoices.FAILED)
  184. template.refresh_from_db()
  185. self.assertEqual(template.data_file_id, obsolete.pk)
  186. self.assertTrue(AutoSyncRecord.objects.filter(pk=autosync.pk, datafile=obsolete).exists())
  187. send.assert_not_called()
  188. def test_sync_rolls_back_create_failure(self):
  189. """A sync that fails creating its new files keeps the synced files."""
  190. with patch.object(RestrictedQuerySet, 'bulk_create', side_effect=IntegrityError('Simulated bulk create error')):
  191. with self.assertRaisesMessage(IntegrityError, 'Simulated bulk create error'):
  192. self.datasource.sync()
  193. self.assert_files_unchanged()
  194. def test_sync_rolls_back_backend_cleanup_failure(self):
  195. """A sync whose backend fails during cleanup keeps the synced files."""
  196. @contextmanager
  197. def fetch(backend):
  198. yield str(self.root)
  199. raise OSError('Simulated cleanup error')
  200. with patch.object(LocalBackend, 'fetch', fetch):
  201. with self.assertRaisesMessage(OSError, 'Simulated cleanup error'):
  202. self.datasource.sync()
  203. self.assert_files_unchanged()
  204. def test_sync_locks_datasource_before_reading_files(self):
  205. """Take the sync lock inside the transaction, before the files are read and the sync completes."""
  206. with CaptureQueriesContext(connection) as context:
  207. self.datasource.sync()
  208. queries = [query['sql'] for query in context.captured_queries]
  209. begins = [i for i, sql in enumerate(queries) if sql.startswith('SAVEPOINT')]
  210. locks = [i for i, sql in enumerate(queries) if 'pg_advisory_xact_lock' in sql]
  211. reads = [i for i, sql in enumerate(queries) if sql.startswith('SELECT') and 'FROM "core_datafile"' in sql]
  212. completions = [
  213. i for i, sql in enumerate(queries) if sql.startswith('UPDATE "core_datasource"') and "'completed'" in sql
  214. ]
  215. commits = [i for i, sql in enumerate(queries) if sql.startswith('RELEASE SAVEPOINT')]
  216. self.assertTrue(locks)
  217. self.assertLess(begins[0], locks[0])
  218. self.assertLess(locks[0], reads[0])
  219. self.assertLess(reads[0], completions[0])
  220. self.assertLess(completions[0], commits[0])
  221. def test_post_sync_failure_preserves_synced_files(self):
  222. """A failing post_sync receiver leaves the completed sync in place."""
  223. with patch.object(post_sync, 'send', side_effect=SyncError('Simulated post_sync error')):
  224. with self.assertRaisesMessage(SyncError, 'Simulated post_sync error'):
  225. self.datasource.sync()
  226. self.assert_files_synced()
  227. def test_sync_pins_datafile_queries_to_transaction_connection(self):
  228. """Run the data file reads, updates and deletes of a sync on its transaction's connection."""
  229. # Django routes a new data file's construction and validation itself
  230. (self.root / 'b.txt').unlink()
  231. with override_settings(DATABASE_ROUTERS=[PinnedConnectionRouter(DataFile)]):
  232. self.datasource.sync()
  233. self.assertEqual(dict(self.datasource.datafiles.values_list('path', 'data')), {'a.txt': b'new\n'})
  234. class ObjectTypeTestCase(TestCase):
  235. def test_create(self):
  236. """
  237. Test that an ObjectType created for a given app_label & model name will be automatically assigned to
  238. the appropriate ContentType.
  239. """
  240. kwargs = {
  241. 'app_label': 'foo',
  242. 'model': 'bar',
  243. }
  244. ct = ContentType.objects.create(**kwargs)
  245. ot = ObjectType.objects.create(**kwargs)
  246. self.assertEqual(ot.contenttype_ptr, ct)
  247. def test_get_by_natural_key(self):
  248. """
  249. Test that get_by_natural_key() returns the appropriate ObjectType.
  250. """
  251. self.assertEqual(
  252. ObjectType.objects.get_by_natural_key('dcim', 'site'),
  253. ObjectType.objects.get(app_label='dcim', model='site')
  254. )
  255. with self.assertRaises(ObjectDoesNotExist):
  256. ObjectType.objects.get_by_natural_key('foo', 'bar')
  257. def test_get_for_id(self):
  258. """
  259. Test that get_by_id() returns the appropriate ObjectType.
  260. """
  261. ot = ObjectType.objects.get_by_natural_key('dcim', 'site')
  262. self.assertEqual(
  263. ObjectType.objects.get_for_id(ot.pk),
  264. ObjectType.objects.get(pk=ot.pk)
  265. )
  266. with self.assertRaises(ObjectDoesNotExist):
  267. ObjectType.objects.get_for_id(0)
  268. def test_get_for_model(self):
  269. """
  270. Test that get_by_model() returns the appropriate ObjectType.
  271. """
  272. self.assertEqual(
  273. ObjectType.objects.get_for_model(Site),
  274. ObjectType.objects.get_by_natural_key('dcim', 'site')
  275. )
  276. def test_get_for_models(self):
  277. """
  278. Test that get_by_models() returns the appropriate ObjectType mapping.
  279. """
  280. self.assertEqual(
  281. ObjectType.objects.get_for_models(Site, Location, Device),
  282. {
  283. Site: ObjectType.objects.get_by_natural_key('dcim', 'site'),
  284. Location: ObjectType.objects.get_by_natural_key('dcim', 'location'),
  285. Device: ObjectType.objects.get_by_natural_key('dcim', 'device'),
  286. }
  287. )
  288. def test_public(self):
  289. """
  290. Test that public() returns only ObjectTypes for public models.
  291. """
  292. public_ots = ObjectType.objects.public()
  293. self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), public_ots)
  294. self.assertNotIn(ObjectType.objects.get_by_natural_key('extras', 'taggeditem'), public_ots)
  295. def test_with_feature(self):
  296. """
  297. Test that with_feature() returns only ObjectTypes for models which support the specified feature.
  298. """
  299. bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
  300. self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
  301. self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
  302. class JobTestCase(TestCase):
  303. def _make_job(self, user, notifications):
  304. """
  305. Create and return a persisted Job with the given user and notifications setting.
  306. """
  307. return Job.objects.create(
  308. name='Test Job',
  309. job_id=uuid.uuid4(),
  310. user=user,
  311. notifications=notifications,
  312. status=JobStatusChoices.STATUS_RUNNING,
  313. )
  314. @patch('core.models.jobs.django_rq.get_queue')
  315. def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
  316. """
  317. Test that when a job is deleted, it's canceled from the correct queue.
  318. """
  319. mock_queue = MagicMock()
  320. mock_rq_job = MagicMock()
  321. mock_queue.fetch_job.return_value = mock_rq_job
  322. mock_get_queue.return_value = mock_queue
  323. def dummy_func(**kwargs):
  324. pass
  325. # Enqueue a job with a custom queue name
  326. custom_queue = 'my_custom_queue'
  327. job = Job.enqueue(
  328. func=dummy_func,
  329. name='Test Job',
  330. queue_name=custom_queue
  331. )
  332. # Reset mock to clear enqueue call
  333. mock_get_queue.reset_mock()
  334. # Delete the job
  335. job.delete()
  336. # Verify the correct queue was used for cancellation
  337. mock_get_queue.assert_called_with(custom_queue)
  338. mock_queue.fetch_job.assert_called_with(str(job.job_id))
  339. mock_rq_job.cancel.assert_called_once()
  340. @patch('core.models.jobs.job_end')
  341. def test_terminate_notification_always(self, mock_job_end):
  342. """
  343. With notifications=always, a Notification should be created for every
  344. terminal status (completed, failed, errored).
  345. """
  346. user = User.objects.create_user(username='notification-always')
  347. for status in (
  348. JobStatusChoices.STATUS_COMPLETED,
  349. JobStatusChoices.STATUS_FAILED,
  350. JobStatusChoices.STATUS_ERRORED,
  351. ):
  352. with self.subTest(status=status):
  353. job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ALWAYS)
  354. job.terminate(status=status)
  355. self.assertEqual(
  356. Notification.objects.filter(user=user, object_id=job.pk).count(),
  357. 1,
  358. msg=f"Expected a notification for status={status} with notifications=always",
  359. )
  360. @patch('core.models.jobs.job_end')
  361. def test_terminate_notification_on_failure(self, mock_job_end):
  362. """
  363. With notifications=on_failure, a Notification should be created only for
  364. non-completed terminal statuses (failed, errored), not for completed.
  365. """
  366. user = User.objects.create_user(username='notification-on-failure')
  367. # No notification on successful completion
  368. job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
  369. job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
  370. self.assertEqual(
  371. Notification.objects.filter(user=user, object_id=job.pk).count(),
  372. 0,
  373. msg="Expected no notification for status=completed with notifications=on_failure",
  374. )
  375. # Notification on failure/error
  376. for status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED):
  377. with self.subTest(status=status):
  378. job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
  379. job.terminate(status=status)
  380. self.assertEqual(
  381. Notification.objects.filter(user=user, object_id=job.pk).count(),
  382. 1,
  383. msg=f"Expected a notification for status={status} with notifications=on_failure",
  384. )
  385. @patch('core.models.jobs.job_end')
  386. def test_terminate_notification_never(self, mock_job_end):
  387. """
  388. With notifications=never, no Notification should be created regardless
  389. of terminal status.
  390. """
  391. user = User.objects.create_user(username='notification-never')
  392. for status in (
  393. JobStatusChoices.STATUS_COMPLETED,
  394. JobStatusChoices.STATUS_FAILED,
  395. JobStatusChoices.STATUS_ERRORED,
  396. ):
  397. with self.subTest(status=status):
  398. job = self._make_job(user, JobNotificationChoices.NOTIFICATION_NEVER)
  399. job.terminate(status=status)
  400. self.assertEqual(
  401. Notification.objects.filter(user=user, object_id=job.pk).count(),
  402. 0,
  403. msg=f"Expected no notification for status={status} with notifications=never",
  404. )
  405. @patch('core.models.jobs.job_end')
  406. def test_execution_time_set_on_terminate(self, mock_job_end):
  407. """
  408. terminate() should set execution_time to (completed - started) for a started job.
  409. """
  410. job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
  411. job.started = timezone.now() - timedelta(seconds=90)
  412. job.save()
  413. job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
  414. self.assertIsNotNone(job.execution_time)
  415. self.assertEqual(job.execution_time, job.completed - job.started)
  416. @patch('core.models.jobs.job_end')
  417. def test_execution_time_none_before_completion(self, mock_job_end):
  418. """
  419. A job which has not completed should have a null execution_time.
  420. """
  421. job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
  422. self.assertIsNone(job.execution_time)
  423. @patch('core.models.jobs.job_end')
  424. def test_execution_time_none_when_never_started(self, mock_job_end):
  425. """
  426. Terminating a job which was never started (no started timestamp) should leave
  427. execution_time null rather than computing a value from a missing start time.
  428. """
  429. job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
  430. self.assertIsNone(job.started)
  431. job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
  432. self.assertIsNone(job.execution_time)
  433. def test_get_absolute_url(self):
  434. job = Job(pk=123, name='Test Job')
  435. self.assertEqual(job.get_absolute_url(), reverse('core:job', args=[123]))
  436. # Jobs for custom scripts link to the script result view
  437. job = Job(pk=123, name='Test Job', object_type=ObjectType.objects.get_for_model(Script), object_id=1)
  438. self.assertEqual(job.get_absolute_url(), reverse('extras:script_result', kwargs={'job_pk': 123}))