test_event_rules.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  1. import json
  2. import logging
  3. import uuid
  4. from io import BytesIO
  5. from unittest import skipIf
  6. from unittest.mock import Mock, patch
  7. import django_rq
  8. from django.conf import settings
  9. from django.http import HttpResponse
  10. from django.test import RequestFactory, TestCase, tag
  11. from django.urls import reverse
  12. from PIL import Image
  13. from requests import Session
  14. from rest_framework import status
  15. from core.choices import ManagedFileRootPathChoices
  16. from core.events import *
  17. from core.models import Job, ObjectType
  18. from dcim.choices import SiteStatusChoices
  19. from dcim.models import DeviceType, Interface, Manufacturer, Site
  20. from extras.choices import EventRuleActionChoices
  21. from extras.events import enqueue_event, flush_events, serialize_for_event
  22. from extras.models import EventRule, Script, ScriptModule, Tag, Webhook
  23. from extras.scripts import Script as ScriptBase
  24. from extras.signals import process_job_end_event_rules
  25. from extras.webhooks import generate_signature, send_webhook
  26. from netbox.context_managers import event_tracking
  27. from utilities.testing import APITestCase, create_test_device
  28. from utilities.testing.mixins import RQQueueTestMixin
  29. class EventRuleTestCase(RQQueueTestMixin, APITestCase):
  30. def setUp(self):
  31. super().setUp()
  32. # Ensure the queue has been cleared for each test
  33. self.queue = django_rq.get_queue('default')
  34. self.queue.empty()
  35. def tearDown(self):
  36. super().tearDown()
  37. # Clear the queue so leftover jobs do not leak to the next test suite
  38. self.queue.empty()
  39. def test_enqueue_event_requires_saved_instance(self):
  40. """enqueue_event raises ValueError for an unsaved instance."""
  41. request = RequestFactory().get('/')
  42. request.id = uuid.uuid4()
  43. request.user = self.user
  44. site = Site(name='Site 1', slug='site-1')
  45. with patch('extras.events.has_feature', return_value=True):
  46. with self.assertRaises(ValueError):
  47. enqueue_event({}, site, request, OBJECT_CREATED)
  48. @classmethod
  49. def setUpTestData(cls):
  50. site_type = ObjectType.objects.get_for_model(Site)
  51. DUMMY_URL = 'http://localhost:9000/'
  52. DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
  53. webhooks = Webhook.objects.bulk_create((
  54. Webhook(name='Webhook 1', payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
  55. Webhook(name='Webhook 2', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
  56. Webhook(name='Webhook 3', payload_url=DUMMY_URL, secret=DUMMY_SECRET),
  57. ))
  58. webhook_type = ObjectType.objects.get(app_label='extras', model='webhook')
  59. event_rules = EventRule.objects.bulk_create((
  60. EventRule(
  61. name='Event Rule 1',
  62. event_types=[OBJECT_CREATED],
  63. action_type=EventRuleActionChoices.WEBHOOK,
  64. action_object_type=webhook_type,
  65. action_object_id=webhooks[0].id,
  66. action_data={"foo": 1},
  67. ),
  68. EventRule(
  69. name='Event Rule 2',
  70. event_types=[OBJECT_UPDATED],
  71. action_type=EventRuleActionChoices.WEBHOOK,
  72. action_object_type=webhook_type,
  73. action_object_id=webhooks[0].id,
  74. action_data={"foo": 2},
  75. ),
  76. EventRule(
  77. name='Event Rule 3',
  78. event_types=[OBJECT_DELETED],
  79. action_type=EventRuleActionChoices.WEBHOOK,
  80. action_object_type=webhook_type,
  81. action_object_id=webhooks[0].id,
  82. action_data={"foo": 3},
  83. ),
  84. ))
  85. for event_rule in event_rules:
  86. event_rule.object_types.set([site_type])
  87. Tag.objects.bulk_create((
  88. Tag(name='Foo', slug='foo'),
  89. Tag(name='Bar', slug='bar'),
  90. Tag(name='Baz', slug='baz'),
  91. ))
  92. def test_eventrule_conditions(self):
  93. """
  94. Test evaluation of EventRule conditions.
  95. """
  96. event_rule = EventRule(
  97. name='Event Rule 1',
  98. event_types=[OBJECT_CREATED, OBJECT_UPDATED],
  99. conditions={
  100. 'and': [
  101. {
  102. 'attr': 'status.value',
  103. 'value': 'active',
  104. }
  105. ]
  106. }
  107. )
  108. # Create a Site to evaluate
  109. site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING)
  110. data = serialize_for_event(site)
  111. # Evaluate the conditions (status='staging')
  112. self.assertFalse(event_rule.eval_conditions(data))
  113. # Change the site's status
  114. site.status = SiteStatusChoices.STATUS_ACTIVE
  115. data = serialize_for_event(site)
  116. # Evaluate the conditions (status='active')
  117. self.assertTrue(event_rule.eval_conditions(data))
  118. def test_single_create_process_eventrule(self):
  119. """
  120. Check that creating an object with an applicable EventRule queues a background task for the rule's action.
  121. """
  122. # Create an object via the REST API
  123. data = {
  124. 'name': 'Site 1',
  125. 'slug': 'site-1',
  126. 'tags': [
  127. {'name': 'Foo'},
  128. {'name': 'Bar'},
  129. ]
  130. }
  131. url = reverse('dcim-api:site-list')
  132. self.add_permissions('dcim.add_site', 'extras.view_tag')
  133. response = self.client.post(url, data, format='json', **self.header)
  134. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  135. self.assertEqual(Site.objects.count(), 1)
  136. self.assertEqual(Site.objects.first().tags.count(), 2)
  137. # Verify that a background task was queued for the new object
  138. self.assertEqual(self.queue.count, 1)
  139. job = self.queue.jobs[0]
  140. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
  141. self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
  142. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  143. self.assertEqual(job.kwargs['data']['id'], response.data['id'])
  144. self.assertEqual(job.kwargs['data']['foo'], 1)
  145. self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
  146. self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1')
  147. self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
  148. def test_bulk_create_process_eventrule(self):
  149. """
  150. Check that bulk creating multiple objects with an applicable EventRule queues a background task for each
  151. new object.
  152. """
  153. # Create multiple objects via the REST API
  154. data = [
  155. {
  156. 'name': 'Site 1',
  157. 'slug': 'site-1',
  158. 'tags': [
  159. {'name': 'Foo'},
  160. {'name': 'Bar'},
  161. ]
  162. },
  163. {
  164. 'name': 'Site 2',
  165. 'slug': 'site-2',
  166. 'tags': [
  167. {'name': 'Foo'},
  168. {'name': 'Bar'},
  169. ]
  170. },
  171. {
  172. 'name': 'Site 3',
  173. 'slug': 'site-3',
  174. 'tags': [
  175. {'name': 'Foo'},
  176. {'name': 'Bar'},
  177. ]
  178. },
  179. ]
  180. url = reverse('dcim-api:site-list')
  181. self.add_permissions('dcim.add_site', 'extras.view_tag')
  182. response = self.client.post(url, data, format='json', **self.header)
  183. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  184. self.assertEqual(Site.objects.count(), 3)
  185. self.assertEqual(Site.objects.first().tags.count(), 2)
  186. # Verify that a background task was queued for each new object
  187. self.assertEqual(self.queue.count, 3)
  188. for i, job in enumerate(self.queue.jobs):
  189. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
  190. self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
  191. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  192. self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
  193. self.assertEqual(job.kwargs['data']['foo'], 1)
  194. self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
  195. self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
  196. self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo'])
  197. def test_single_update_process_eventrule(self):
  198. """
  199. Check that updating an object with an applicable EventRule queues a background task for the rule's action.
  200. """
  201. site = Site.objects.create(name='Site 1', slug='site-1')
  202. site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
  203. # Update an object via the REST API
  204. data = {
  205. 'name': 'Site X',
  206. 'comments': 'Updated the site',
  207. 'tags': [
  208. {'name': 'Baz'}
  209. ]
  210. }
  211. url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
  212. self.add_permissions('dcim.change_site', 'extras.view_tag')
  213. response = self.client.patch(url, data, format='json', **self.header)
  214. self.assertHttpStatus(response, status.HTTP_200_OK)
  215. # Verify that a background task was queued for the updated object
  216. self.assertEqual(self.queue.count, 1)
  217. job = self.queue.jobs[0]
  218. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
  219. self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
  220. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  221. self.assertEqual(job.kwargs['data']['id'], site.pk)
  222. self.assertEqual(job.kwargs['data']['foo'], 2)
  223. self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
  224. self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
  225. self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
  226. self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X')
  227. self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
  228. def test_bulk_update_process_eventrule(self):
  229. """
  230. Check that bulk updating multiple objects with an applicable EventRule queues a background task for each
  231. updated object.
  232. """
  233. sites = (
  234. Site(name='Site 1', slug='site-1'),
  235. Site(name='Site 2', slug='site-2'),
  236. Site(name='Site 3', slug='site-3'),
  237. )
  238. Site.objects.bulk_create(sites)
  239. for site in sites:
  240. site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
  241. # Update three objects via the REST API
  242. data = [
  243. {
  244. 'id': sites[0].pk,
  245. 'name': 'Site X',
  246. 'tags': [
  247. {'name': 'Baz'}
  248. ]
  249. },
  250. {
  251. 'id': sites[1].pk,
  252. 'name': 'Site Y',
  253. 'tags': [
  254. {'name': 'Baz'}
  255. ]
  256. },
  257. {
  258. 'id': sites[2].pk,
  259. 'name': 'Site Z',
  260. 'tags': [
  261. {'name': 'Baz'}
  262. ]
  263. },
  264. ]
  265. url = reverse('dcim-api:site-list')
  266. self.add_permissions('dcim.change_site', 'extras.view_tag')
  267. response = self.client.patch(url, data, format='json', **self.header)
  268. self.assertHttpStatus(response, status.HTTP_200_OK)
  269. # Verify that a background task was queued for each updated object
  270. self.assertEqual(self.queue.count, 3)
  271. for i, job in enumerate(self.queue.jobs):
  272. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
  273. self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
  274. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  275. self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
  276. self.assertEqual(job.kwargs['data']['foo'], 2)
  277. self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
  278. self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
  279. self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
  280. self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name'])
  281. self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz'])
  282. def test_single_delete_process_eventrule(self):
  283. """
  284. Check that deleting an object with an applicable EventRule queues a background task for the rule's action.
  285. """
  286. site = Site.objects.create(name='Site 1', slug='site-1')
  287. site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
  288. # Delete an object via the REST API
  289. url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
  290. self.add_permissions('dcim.delete_site')
  291. response = self.client.delete(url, **self.header)
  292. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  293. # Verify that a task was queued for the deleted object
  294. self.assertEqual(self.queue.count, 1)
  295. job = self.queue.jobs[0]
  296. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
  297. self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
  298. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  299. self.assertEqual(job.kwargs['data']['id'], site.pk)
  300. self.assertEqual(job.kwargs['data']['foo'], 3)
  301. self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
  302. self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
  303. def test_bulk_delete_process_eventrule(self):
  304. """
  305. Check that bulk deleting multiple objects with an applicable EventRule queues a background task for each
  306. deleted object.
  307. """
  308. sites = (
  309. Site(name='Site 1', slug='site-1'),
  310. Site(name='Site 2', slug='site-2'),
  311. Site(name='Site 3', slug='site-3'),
  312. )
  313. Site.objects.bulk_create(sites)
  314. for site in sites:
  315. site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
  316. # Delete three objects via the REST API
  317. data = [
  318. {'id': site.pk} for site in sites
  319. ]
  320. url = reverse('dcim-api:site-list')
  321. self.add_permissions('dcim.delete_site')
  322. response = self.client.delete(url, data, format='json', **self.header)
  323. self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
  324. # Verify that a background task was queued for each deleted object
  325. self.assertEqual(self.queue.count, 3)
  326. for i, job in enumerate(self.queue.jobs):
  327. self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
  328. self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
  329. self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site))
  330. self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
  331. self.assertEqual(job.kwargs['data']['foo'], 3)
  332. self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
  333. self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo'])
  334. @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
  335. def test_send_webhook(self):
  336. request_id = uuid.uuid4()
  337. url_path = reverse('dcim:site_add')
  338. def dummy_send(_, request, **kwargs):
  339. """
  340. A dummy implementation of Session.send() to be used for testing.
  341. Always returns a 200 HTTP response.
  342. """
  343. event = EventRule.objects.get(name='Event Rule 1')
  344. webhook = event.action_object
  345. signature = generate_signature(request.body, webhook.secret)
  346. # Validate the outgoing request headers
  347. self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
  348. self.assertEqual(request.headers['X-Hook-Signature'], signature)
  349. self.assertEqual(request.headers['X-Foo'], 'Bar')
  350. # Validate the outgoing request body
  351. body = json.loads(request.body)
  352. self.assertEqual(body['event'], 'created')
  353. self.assertEqual(body['timestamp'], job.kwargs['timestamp'])
  354. self.assertEqual(body['object_type'], 'dcim.site')
  355. self.assertEqual(body['username'], 'testuser')
  356. self.assertEqual(body['request_id'], str(request_id))
  357. self.assertEqual(body['data']['name'], 'Site 1')
  358. self.assertEqual(body['data']['foo'], 1)
  359. self.assertEqual(body['context']['foo'], 123) # From netbox.tests.dummy_plugin
  360. self.assertEqual(body['request']['id'], str(request_id))
  361. self.assertEqual(body['request']['method'], 'GET')
  362. self.assertEqual(body['request']['path'], url_path)
  363. self.assertEqual(body['request']['user'], 'testuser')
  364. return HttpResponse()
  365. # Create a dummy request
  366. request = RequestFactory().get(url_path)
  367. request.id = request_id
  368. request.user = self.user
  369. # Enqueue a webhook for processing
  370. webhooks_queue = {}
  371. site = Site.objects.create(name='Site 1', slug='site-1')
  372. enqueue_event(
  373. webhooks_queue,
  374. instance=site,
  375. request=request,
  376. event_type=OBJECT_CREATED,
  377. )
  378. flush_events(list(webhooks_queue.values()))
  379. # Retrieve the job from queue
  380. job = self.queue.jobs[0]
  381. # Patch the Session object with our dummy_send() method, then process the webhook for sending
  382. with patch.object(Session, 'send', dummy_send):
  383. send_webhook(**job.kwargs)
  384. def test_job_completed_webhook_username_fallback(self):
  385. """
  386. Ensure job_end event processing can enqueue a webhook even when the EventContext
  387. lacks legacy request attributes (e.g. `username`).
  388. The job_start/job_end signal receivers only populate `user` and `data`, so webhook
  389. processing must derive the username from the user object (or tolerate it being unset).
  390. """
  391. script_type = ObjectType.objects.get_for_model(Script)
  392. webhook_type = ObjectType.objects.get_for_model(Webhook)
  393. webhook = Webhook.objects.get(name='Webhook 1')
  394. event_rule = EventRule.objects.create(
  395. name='Event Rule Job Completed',
  396. event_types=[JOB_COMPLETED],
  397. action_type=EventRuleActionChoices.WEBHOOK,
  398. action_object_type=webhook_type,
  399. action_object_id=webhook.pk,
  400. )
  401. event_rule.object_types.set([script_type])
  402. # Mimic the `core.job_end` signal sender expected by extras.signals.process_job_end_event_rules
  403. # (notably: no request, and thus no legacy `username`)
  404. sender = Mock(object_type=script_type, data={}, user=self.user)
  405. process_job_end_event_rules(sender)
  406. self.assertEqual(self.queue.count, 1)
  407. job = self.queue.jobs[0]
  408. self.assertEqual(job.kwargs['event_rule'], event_rule)
  409. self.assertEqual(job.kwargs['event_type'], JOB_COMPLETED)
  410. self.assertEqual(job.kwargs['object_type'], script_type)
  411. self.assertEqual(job.kwargs['username'], self.user.username)
  412. def test_duplicate_enqueue_refreshes_lazy_payload(self):
  413. """
  414. When the same object is enqueued more than once in a single request,
  415. lazy serialization should use the most recently enqueued instance while
  416. preserving the original event['object'] reference.
  417. """
  418. request = RequestFactory().get(reverse('dcim:site_add'))
  419. request.id = uuid.uuid4()
  420. request.user = self.user
  421. site = Site.objects.create(name='Site 1', slug='site-1')
  422. stale_site = Site.objects.get(pk=site.pk)
  423. queue = {}
  424. enqueue_event(queue, stale_site, request, OBJECT_UPDATED)
  425. event = queue[f'dcim.site:{site.pk}']
  426. # Data should not be materialized yet (lazy serialization)
  427. self.assertNotIn('data', event.data)
  428. fresh_site = Site.objects.get(pk=site.pk)
  429. fresh_site.description = 'foo'
  430. fresh_site.save()
  431. enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
  432. # The original object reference should be preserved
  433. self.assertIs(event['object'], stale_site)
  434. # But serialized data should reflect the fresher instance
  435. self.assertEqual(event['data']['description'], 'foo')
  436. self.assertEqual(event['snapshots']['postchange']['description'], 'foo')
  437. def test_duplicate_enqueue_invalidates_materialized_data(self):
  438. """
  439. If event['data'] has already been materialized before a second enqueue
  440. for the same object, the stale payload should be discarded and rebuilt
  441. from the fresher instance on next access.
  442. """
  443. request = RequestFactory().get(reverse('dcim:site_add'))
  444. request.id = uuid.uuid4()
  445. request.user = self.user
  446. site = Site.objects.create(name='Site 1', slug='site-1')
  447. queue = {}
  448. enqueue_event(queue, site, request, OBJECT_UPDATED)
  449. event = queue[f'dcim.site:{site.pk}']
  450. # Force early materialization
  451. self.assertEqual(event['data']['description'], '')
  452. # Now update and re-enqueue
  453. fresh_site = Site.objects.get(pk=site.pk)
  454. fresh_site.description = 'updated'
  455. fresh_site.save()
  456. enqueue_event(queue, fresh_site, request, OBJECT_UPDATED)
  457. # Stale data should have been invalidated; new access should reflect update
  458. self.assertEqual(event['data']['description'], 'updated')
  459. def test_update_then_delete_enqueue_freezes_payload(self):
  460. """
  461. When an update event is coalesced with a subsequent delete, the event
  462. type should be promoted to OBJECT_DELETED and the payload should be
  463. eagerly frozen (since the object will be inaccessible after deletion).
  464. """
  465. request = RequestFactory().get(reverse('dcim:site_add'))
  466. request.id = uuid.uuid4()
  467. request.user = self.user
  468. site = Site.objects.create(name='Site 1', slug='site-1')
  469. queue = {}
  470. enqueue_event(queue, site, request, OBJECT_UPDATED)
  471. event = queue[f'dcim.site:{site.pk}']
  472. enqueue_event(queue, site, request, OBJECT_DELETED)
  473. # Event type should have been promoted
  474. self.assertEqual(event['event_type'], OBJECT_DELETED)
  475. # Data should already be materialized (frozen), not lazy
  476. self.assertIn('data', event.data)
  477. self.assertEqual(event['data']['name'], 'Site 1')
  478. self.assertIsNone(event['snapshots']['postchange'])
  479. @tag('regression') # #21338
  480. def test_cable_creation_event_payload_includes_connected_endpoints(self):
  481. """
  482. Interface update events queued during cable creation must include the
  483. peer interface in connected_endpoints and link_peers.
  484. """
  485. webhook = Webhook.objects.get(name='Webhook 1')
  486. event_rule = EventRule.objects.create(
  487. name='Interface Update Rule',
  488. event_types=[OBJECT_UPDATED],
  489. action_type=EventRuleActionChoices.WEBHOOK,
  490. action_object_type=ObjectType.objects.get_for_model(Webhook),
  491. action_object_id=webhook.id,
  492. )
  493. event_rule.object_types.set([ObjectType.objects.get_for_model(Interface)])
  494. device = create_test_device('Device 1')
  495. interface_a = Interface.objects.create(device=device, name='eth0')
  496. interface_b = Interface.objects.create(device=device, name='eth1')
  497. # Create a cable between the two interfaces via the REST API
  498. data = {
  499. 'a_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_a.pk}],
  500. 'b_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_b.pk}],
  501. }
  502. url = reverse('dcim-api:cable-list')
  503. self.add_permissions('dcim.add_cable')
  504. response = self.client.post(url, data, format='json', **self.header)
  505. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  506. # One update event was queued for each interface
  507. self.assertEqual(self.queue.count, 2)
  508. payloads = {job.kwargs['data']['id']: job.kwargs['data'] for job in self.queue.jobs}
  509. peers = {interface_a.pk: interface_b.pk, interface_b.pk: interface_a.pk}
  510. self.assertEqual(set(payloads), set(peers))
  511. for interface_id, payload in payloads.items():
  512. peer_id = peers[interface_id]
  513. self.assertIsNotNone(payload['connected_endpoints'])
  514. self.assertEqual([endpoint['id'] for endpoint in payload['connected_endpoints']], [peer_id])
  515. self.assertEqual([peer['id'] for peer in payload['link_peers']], [peer_id])
  516. self.assertTrue(payload['connected_endpoints_reachable'])
  517. def test_duplicate_triggers(self):
  518. """
  519. Test for erroneous duplicate event triggers resulting from saving an object multiple times
  520. within the span of a single request.
  521. """
  522. url = reverse('dcim:site_add')
  523. request = RequestFactory().get(url)
  524. request.id = uuid.uuid4()
  525. request.user = self.user
  526. # Test create & update
  527. with event_tracking(request):
  528. site = Site(name='Site 1', slug='site-1')
  529. site.save()
  530. site.description = 'foo'
  531. site.save()
  532. self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
  533. job = self.queue.get_jobs()[0]
  534. self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
  535. self.queue.empty()
  536. # Test multiple updates
  537. site = Site.objects.create(name='Site 2', slug='site-2')
  538. with event_tracking(request):
  539. site.description = 'foo'
  540. site.save()
  541. site.description = 'bar'
  542. site.save()
  543. self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
  544. job = self.queue.get_jobs()[0]
  545. self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
  546. self.queue.empty()
  547. # Test update & delete
  548. site = Site.objects.create(name='Site 3', slug='site-3')
  549. with event_tracking(request):
  550. site.description = 'foo'
  551. site.save()
  552. site.delete()
  553. self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
  554. job = self.queue.get_jobs()[0]
  555. self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
  556. self.queue.empty()
  557. def test_non_dict_action_data_does_not_crash_flush(self):
  558. """
  559. Pre-existing non-dict action_data must not cause flush_events() to
  560. raise.
  561. """
  562. # flush_events() logs a warning about the invalid action_data; mute it so the expected
  563. # message doesn't clutter the test runner's output.
  564. events_logger = logging.getLogger('netbox.events_processor')
  565. original_level = events_logger.level
  566. events_logger.setLevel(logging.CRITICAL)
  567. self.addCleanup(events_logger.setLevel, original_level)
  568. site_type = ObjectType.objects.get_for_model(Site)
  569. webhook = Webhook.objects.get(name='Webhook 1')
  570. webhook_type = ObjectType.objects.get_for_model(Webhook)
  571. bad_rule = EventRule.objects.create(
  572. name='Bad action_data rule',
  573. event_types=[OBJECT_CREATED],
  574. action_type=EventRuleActionChoices.WEBHOOK,
  575. action_object_type=webhook_type,
  576. action_object_id=webhook.pk,
  577. action_data={},
  578. )
  579. bad_rule.object_types.set([site_type])
  580. # Simulate a legacy row that predates model validation.
  581. EventRule.objects.filter(pk=bad_rule.pk).update(action_data='not a dict')
  582. url = reverse('dcim-api:site-list')
  583. self.add_permissions('dcim.add_site')
  584. response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
  585. self.assertHttpStatus(response, status.HTTP_201_CREATED)
  586. @tag('regression')
  587. def test_eventrule_script_action_with_object_image_files(self):
  588. """
  589. Verify that a Script event-rule action can be enqueued and executed cleanly when the
  590. triggering object carries uploaded files (e.g. DeviceType images).
  591. This is a regression test for issue #22376.
  592. """
  593. # Create a dummy script class and an instance of it
  594. class DummyScript(ScriptBase):
  595. class Meta:
  596. name = "Dummy Script"
  597. def run(self, data, commit=True):
  598. return "finished successfully"
  599. dummy_script = DummyScript()
  600. # Create ScriptModule and Script
  601. with patch.object(ScriptModule, 'sync_classes'):
  602. module = ScriptModule.objects.create(
  603. file_root=ManagedFileRootPathChoices.SCRIPTS,
  604. file_path='dummy_script.py',
  605. )
  606. script = Script.objects.create(
  607. module=module,
  608. name='Dummy Script',
  609. is_executable=True,
  610. )
  611. script_type = ObjectType.objects.get_for_model(Script)
  612. # Create an event rule that triggers on DeviceType update with Script action
  613. devicetype_type = ObjectType.objects.get_for_model(DeviceType)
  614. event_rule = EventRule.objects.create(
  615. name='Test Script Event Rule with Files',
  616. event_types=[OBJECT_UPDATED],
  617. action_type=EventRuleActionChoices.SCRIPT,
  618. action_object_type=script_type,
  619. action_object_id=script.pk,
  620. )
  621. event_rule.object_types.set([devicetype_type])
  622. # Create a manufacturer and DeviceType
  623. manufacturer = Manufacturer.objects.create(
  624. name='Test Manufacturer',
  625. slug='test-manufacturer',
  626. )
  627. devicetype = DeviceType.objects.create(
  628. model='Test DeviceType',
  629. slug="test-devicetype",
  630. manufacturer=manufacturer,
  631. )
  632. # Create an image file
  633. image = BytesIO()
  634. Image.new('RGB', (1, 1)).save(image, format='PNG')
  635. image.name = 'test_image.png'
  636. image.seek(0)
  637. # PATCH the DeviceType via REST API to add the image
  638. data = {
  639. 'front_image': image,
  640. }
  641. url = reverse('dcim-api:devicetype-detail', kwargs={'pk': devicetype.pk})
  642. self.add_permissions('dcim.change_devicetype')
  643. # Mock the script's python_class to prevent the test from trying to load from disk
  644. with patch.object(Script, 'python_class') as mock:
  645. mock.return_value = dummy_script
  646. # Since in core/models/jobs.py Jobs are enqueued with a transaction.on_commit-handler
  647. # we simulate commit by using captureOnCommitCallbacks context manager
  648. with self.captureOnCommitCallbacks(execute=True):
  649. response = self.client.patch(url, data, format='multipart', **self.header)
  650. self.assertHttpStatus(response, status.HTTP_200_OK)
  651. # Assert that the script job was enqueued cleanly and is waiting for execution
  652. self.assertEqual(self.queue.count, 1)
  653. script_job = Job.objects.filter(name=dummy_script.name).last()
  654. self.assertEqual(script_job.status, "pending")
  655. # silence rqworker (cleaner output) and trigger job execution
  656. logging.getLogger('rq.worker').setLevel(logging.ERROR)
  657. self.run_rq_jobs('default')
  658. # Assert that our script was executed without any errors
  659. script_job.refresh_from_db()
  660. self.assertEqual(script_job.status, "completed")
  661. self.assertEqual(script_job.data.get('output', ''), "finished successfully")
  662. @tag('regression')
  663. def test_eventrule_webhook_action_with_object_image_files(self):
  664. """
  665. Verify that a Webhook event-rule action can be enqueued and executed cleanly when
  666. the triggering object carries uploaded files (e.g. DeviceType images).
  667. This is a regression test for issue #20873.
  668. """
  669. # Create an event rule that triggers on DeviceType update with Script action
  670. webhook = Webhook.objects.get(name='Webhook 1')
  671. webhook_type = ObjectType.objects.get_for_model(Webhook)
  672. devicetype_type = ObjectType.objects.get_for_model(DeviceType)
  673. event_rule = EventRule.objects.create(
  674. name='Test Webhook Event Rule with Files',
  675. event_types=[OBJECT_UPDATED],
  676. action_type=EventRuleActionChoices.WEBHOOK,
  677. action_object_type=webhook_type,
  678. action_object_id=webhook.pk,
  679. )
  680. event_rule.object_types.set([devicetype_type])
  681. # Create a manufacturer and DeviceType
  682. manufacturer = Manufacturer.objects.create(
  683. name='Test Manufacturer',
  684. slug='test-manufacturer',
  685. )
  686. devicetype = DeviceType.objects.create(
  687. model='Test DeviceType',
  688. slug="test-devicetype",
  689. manufacturer=manufacturer,
  690. )
  691. # Create an image file
  692. image = BytesIO()
  693. Image.new('RGB', (1, 1)).save(image, format='PNG')
  694. image.name = 'test_image.png'
  695. image.seek(0)
  696. # PATCH the DeviceType via REST API to add the image
  697. data = {
  698. 'front_image': image,
  699. }
  700. url = reverse('dcim-api:devicetype-detail', kwargs={'pk': devicetype.pk})
  701. self.add_permissions('dcim.change_devicetype')
  702. response = self.client.patch(url, data, format='multipart', **self.header)
  703. self.assertHttpStatus(response, status.HTTP_200_OK)
  704. # Assert that the webhook job was enqueued cleanly
  705. self.assertEqual(self.queue.count, 1)
  706. job = self.queue.jobs[0]
  707. self.assertEqual(job.kwargs['event_rule'], event_rule)
  708. self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
  709. class WebhookRenderHeadersTest(TestCase):
  710. def test_render_headers(self):
  711. """Basic header rendering with Jinja2 interpolation."""
  712. webhook = Webhook(
  713. name='Webhook 1',
  714. payload_url='http://localhost:9000/',
  715. additional_headers='X-Foo: Bar\nX-Object: {{ data.name }}',
  716. )
  717. headers = webhook.render_headers({'data': {'name': 'Site 1'}})
  718. self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Object': 'Site 1'})
  719. def test_render_headers_multiline_block(self):
  720. """A multi-line Jinja2 block (e.g. a loop generating headers) must render against the full template."""
  721. webhook = Webhook(
  722. name='Webhook 1',
  723. payload_url='http://localhost:9000/',
  724. additional_headers=(
  725. '{% for k, v in data.headers.items() %}X-{{ k }}: {{ v }}\n'
  726. '{% endfor %}'
  727. ),
  728. )
  729. headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
  730. self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
  731. def test_render_headers_skips_blank_lines(self):
  732. """Blank lines in the rendered output (e.g. from Jinja2 block tags) must be skipped, not raise."""
  733. webhook = Webhook(
  734. name='Webhook 1',
  735. payload_url='http://localhost:9000/',
  736. # Block tags on their own lines leave behind blank lines once rendered
  737. additional_headers=(
  738. '{% for k, v in data.headers.items() %}\n'
  739. 'X-{{ k }}: {{ v }}\n'
  740. '{% endfor %}'
  741. ),
  742. )
  743. headers = webhook.render_headers({'data': {'headers': {'Foo': '1', 'Bar': '2'}}})
  744. self.assertEqual(headers, {'X-Foo': '1', 'X-Bar': '2'})
  745. def test_render_headers_skips_lines_without_separator(self):
  746. """A non-blank line lacking a 'Name: Value' separator must be skipped, not raise."""
  747. webhook = Webhook(
  748. name='Webhook 1',
  749. payload_url='http://localhost:9000/',
  750. additional_headers='X-Foo: Bar\nthis line has no colon\nX-Baz: Qux',
  751. )
  752. headers = webhook.render_headers({})
  753. self.assertEqual(headers, {'X-Foo': 'Bar', 'X-Baz': 'Qux'})
  754. def test_render_headers_header_safe_filter_available(self):
  755. """
  756. The `header_safe` filter must be available when rendering headers, and must strip control characters
  757. (including CR/LF) so that untrusted data cannot smuggle additional headers via CR/LF injection.
  758. """
  759. webhook = Webhook(
  760. name='Webhook 1',
  761. payload_url='http://localhost:9000/',
  762. additional_headers='X-Object: {{ data.name | header_safe }}',
  763. )
  764. headers = webhook.render_headers({'data': {'name': 'legit\r\nX-Injected: evil\x00'}})
  765. # The injected newline is stripped, so only a single (sanitized) header is produced
  766. self.assertEqual(list(headers.keys()), ['X-Object'])
  767. self.assertNotIn('X-Injected', headers)
  768. self.assertEqual(headers['X-Object'], 'legitX-Injected: evil')