From 5ca30fe81b80d3638426cdef28b379910e6cee62 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 31 Oct 2023 11:06:34 -0700 Subject: [PATCH] 14132 restore webhook name --- netbox/extras/forms/model_forms.py | 2 +- netbox/extras/migrations/0099_eventrule.py | 8 - netbox/extras/models/models.py | 9 +- netbox/extras/tests/test_api.py | 54 ++++ netbox/extras/tests/test_event_rules.py | 332 +++++++++++++++++++++ netbox/templates/extras/webhook.html | 13 + 6 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 netbox/extras/tests/test_event_rules.py diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 923fb06c2..a06e500d1 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -226,7 +226,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm): class WebhookForm(NetBoxModelForm): fieldsets = ( - (_('Webhook'), ('tags',)), + (_('Webhook'), ('name', 'tags',)), (_('HTTP Request'), ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), diff --git a/netbox/extras/migrations/0099_eventrule.py b/netbox/extras/migrations/0099_eventrule.py index 9312a29f3..79cb002ee 100644 --- a/netbox/extras/migrations/0099_eventrule.py +++ b/netbox/extras/migrations/0099_eventrule.py @@ -85,10 +85,6 @@ class Migration(migrations.Migration): }, ), migrations.RunPython(move_webhooks), - migrations.AlterModelOptions( - name='webhook', - options={'ordering': ('payload_url',)}, - ), migrations.RemoveConstraint( model_name='webhook', name='extras_webhook_unique_payload_url_types', @@ -105,10 +101,6 @@ class Migration(migrations.Migration): model_name='webhook', name='enabled', ), - migrations.RemoveField( - model_name='webhook', - name='name', - ), migrations.RemoveField( model_name='webhook', name='type_create', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index dd5e83eb7..137b2cb57 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -157,6 +157,11 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo """ events = GenericRelation(EventRule) + name = models.CharField( + verbose_name=_('name'), + max_length=150, + unique=True + ) payload_url = models.CharField( max_length=500, verbose_name=_('URL'), @@ -223,12 +228,12 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo ) class Meta: - ordering = ('payload_url',) + ordering = ('name',) verbose_name = _('webhook') verbose_name_plural = _('webhooks') def __str__(self): - return self.payload_url + return self.name def get_absolute_url(self): return reverse('extras:webhook', args=[self.pk]) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 255457f21..5d0bd3797 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -27,6 +27,60 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class EventRuleTest(APIViewTestCases.APIViewTestCase): + model = EventRule + brief_fields = ['display', 'id', 'name', 'url'] + create_data = [ + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Event Rule 4', + 'type_create': True, + 'payload_url': 'http://example.com/?4', + }, + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Event Rule 5', + 'type_update': True, + 'payload_url': 'http://example.com/?5', + }, + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Event Rule 6', + 'type_delete': True, + 'payload_url': 'http://example.com/?6', + }, + ] + bulk_update_data = { + 'ssl_verification': False, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + rack_ct = ContentType.objects.get_for_model(Rack) + + webhooks = ( + Webhook( + name='Webhook 1', + type_create=True, + payload_url='http://example.com/?1', + ), + Webhook( + name='Webhook 2', + type_update=True, + payload_url='http://example.com/?1', + ), + Webhook( + name='Webhook 3', + type_delete=True, + payload_url='http://example.com/?1', + ), + ) + Webhook.objects.bulk_create(webhooks) + for webhook in webhooks: + webhook.content_types.add(site_ct, rack_ct) + + class WebhookTest(APIViewTestCases.APIViewTestCase): model = Webhook brief_fields = ['display', 'id', 'name', 'url'] diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py new file mode 100644 index 000000000..d8bfe4229 --- /dev/null +++ b/netbox/extras/tests/test_event_rules.py @@ -0,0 +1,332 @@ +import json +import uuid +from unittest.mock import patch + +import django_rq +from django.contrib.contenttypes.models import ContentType +from django.http import HttpResponse +from django.urls import reverse +from requests import Session +from rest_framework import status + +from dcim.choices import SiteStatusChoices +from dcim.models import Site +from extras.choices import ObjectChangeActionChoices +from extras.models import Tag, EventRule +from extras.webhooks import enqueue_object, flush_events, generate_signature, serialize_for_webhook +from extras.webhooks_worker import eval_conditions, process_webhook +from utilities.testing import APITestCase + + +class EventRuleTest(APITestCase): + + def setUp(self): + super().setUp() + + # Ensure the queue has been cleared for each test + self.queue = django_rq.get_queue('default') + self.queue.empty() + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get_for_model(Site) + DUMMY_URL = 'http://localhost:9000/' + DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING' + + webhooks = EventRule.objects.bulk_create(( + Webhook(name='Webhook 1', type_create=True), + Webhook(name='Webhook 2', type_update=True), + Webhook(name='Webhook 3', type_delete=True), + )) + for webhook in webhooks: + webhook.content_types.set([site_ct]) + + Tag.objects.bulk_create(( + Tag(name='Foo', slug='foo'), + Tag(name='Bar', slug='bar'), + Tag(name='Baz', slug='baz'), + )) + + def test_enqueue_webhook_create(self): + # Create an object via the REST API + data = { + 'name': 'Site 1', + 'slug': 'site-1', + 'tags': [ + {'name': 'Foo'}, + {'name': 'Bar'}, + ] + } + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Site.objects.count(), 1) + self.assertEqual(Site.objects.first().tags.count(), 2) + + # Verify that a job was queued for the object creation webhook + self.assertEqual(self.queue.count, 1) + job = self.queue.jobs[0] + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], response.data['id']) + self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) + self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site 1') + self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo']) + + def test_enqueue_webhook_bulk_create(self): + # Create multiple objects via the REST API + data = [ + { + 'name': 'Site 1', + 'slug': 'site-1', + 'tags': [ + {'name': 'Foo'}, + {'name': 'Bar'}, + ] + }, + { + 'name': 'Site 2', + 'slug': 'site-2', + 'tags': [ + {'name': 'Foo'}, + {'name': 'Bar'}, + ] + }, + { + 'name': 'Site 3', + 'slug': 'site-3', + 'tags': [ + {'name': 'Foo'}, + {'name': 'Bar'}, + ] + }, + ] + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Site.objects.count(), 3) + self.assertEqual(Site.objects.first().tags.count(), 2) + + # Verify that a webhook was queued for each object + self.assertEqual(self.queue.count, 3) + for i, job in enumerate(self.queue.jobs): + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], response.data[i]['id']) + self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) + self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name']) + self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Bar', 'Foo']) + + def test_enqueue_webhook_update(self): + site = Site.objects.create(name='Site 1', slug='site-1') + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) + + # Update an object via the REST API + data = { + 'name': 'Site X', + 'comments': 'Updated the site', + 'tags': [ + {'name': 'Baz'} + ] + } + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + self.add_permissions('dcim.change_site') + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # Verify that a job was queued for the object update webhook + self.assertEqual(self.queue.count, 1) + job = self.queue.jobs[0] + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) + self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1') + self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + self.assertEqual(job.kwargs['snapshots']['postchange']['name'], 'Site X') + self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz']) + + def test_enqueue_webhook_bulk_update(self): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + for site in sites: + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) + + # Update three objects via the REST API + data = [ + { + 'id': sites[0].pk, + 'name': 'Site X', + 'tags': [ + {'name': 'Baz'} + ] + }, + { + 'id': sites[1].pk, + 'name': 'Site Y', + 'tags': [ + {'name': 'Baz'} + ] + }, + { + 'id': sites[2].pk, + 'name': 'Site Z', + 'tags': [ + {'name': 'Baz'} + ] + }, + ] + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.change_site') + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # Verify that a job was queued for the object update webhook + self.assertEqual(self.queue.count, 3) + for i, job in enumerate(self.queue.jobs): + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], data[i]['id']) + self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) + self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) + self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + self.assertEqual(job.kwargs['snapshots']['postchange']['name'], response.data[i]['name']) + self.assertEqual(job.kwargs['snapshots']['postchange']['tags'], ['Baz']) + + def test_enqueue_webhook_delete(self): + site = Site.objects.create(name='Site 1', slug='site-1') + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) + + # Delete an object via the REST API + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + self.add_permissions('dcim.delete_site') + response = self.client.delete(url, **self.header) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + + # Verify that a job was queued for the object update webhook + self.assertEqual(self.queue.count, 1) + job = self.queue.jobs[0] + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1') + self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + + def test_enqueue_webhook_bulk_delete(self): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + for site in sites: + site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar'])) + + # Delete three objects via the REST API + data = [ + {'id': site.pk} for site in sites + ] + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.delete_site') + response = self.client.delete(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + + # Verify that a job was queued for the object update webhook + self.assertEqual(self.queue.count, 3) + for i, job in enumerate(self.queue.jobs): + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True)) + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['data']['id'], sites[i].pk) + self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) + self.assertEqual(job.kwargs['snapshots']['prechange']['tags'], ['Bar', 'Foo']) + + def test_webhook_conditions(self): + # Create a conditional Webhook + webhook = Webhook( + name='Conditional Webhook', + type_create=True, + type_update=True, + payload_url='http://localhost:9000/', + conditions={ + 'and': [ + { + 'attr': 'status.value', + 'value': 'active', + } + ] + } + ) + + # Create a Site to evaluate + site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_STAGING) + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='staging') + self.assertFalse(eval_conditions(webhook, data)) + + # Change the site's status + site.status = SiteStatusChoices.STATUS_ACTIVE + data = serialize_for_webhook(site) + + # Evaluate the conditions (status='active') + self.assertTrue(eval_conditions(webhook, data)) + + def test_webhooks_worker(self): + + request_id = uuid.uuid4() + + def dummy_send(_, request, **kwargs): + """ + A dummy implementation of Session.send() to be used for testing. + Always returns a 200 HTTP response. + """ + webhook = Webhook.objects.get(type_create=True) + signature = generate_signature(request.body, webhook.secret) + + # Validate the outgoing request headers + self.assertEqual(request.headers['Content-Type'], webhook.http_content_type) + self.assertEqual(request.headers['X-Hook-Signature'], signature) + self.assertEqual(request.headers['X-Foo'], 'Bar') + + # Validate the outgoing request body + body = json.loads(request.body) + self.assertEqual(body['event'], 'created') + self.assertEqual(body['timestamp'], job.kwargs['timestamp']) + self.assertEqual(body['model'], 'site') + self.assertEqual(body['username'], 'testuser') + self.assertEqual(body['request_id'], str(request_id)) + self.assertEqual(body['data']['name'], 'Site 1') + + return HttpResponse() + + # Enqueue a webhook for processing + events_queue = [] + site = Site.objects.create(name='Site 1', slug='site-1') + enqueue_object( + events_queue, + instance=site, + user=self.user, + request_id=request_id, + action=ObjectChangeActionChoices.ACTION_CREATE + ) + flush_events(events_queue) + + # Retrieve the job from queue + job = self.queue.jobs[0] + + # Patch the Session object with our dummy_send() method, then process the webhook for sending + with patch.object(Session, 'send', dummy_send) as mock_send: + process_webhook(**job.kwargs) diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index 83db1a0f6..c4b41faa1 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -6,6 +6,19 @@ {% block content %}
+
+
+ {% trans "Webhook" %} +
+
+ + + + + +
{% trans "Name" %}{{ object.name }}
+
+
{% trans "HTTP Request" %}