diff --git a/settings/common.py b/settings/common.py index e27df665..12e2275c 100644 --- a/settings/common.py +++ b/settings/common.py @@ -197,6 +197,7 @@ INSTALLED_APPS = [ "taiga.hooks.github", "taiga.hooks.gitlab", "taiga.hooks.bitbucket", + "taiga.webhooks", "rest_framework", "djmail", @@ -366,6 +367,7 @@ GITLAB_VALID_ORIGIN_IPS = [] EXPORTS_TTL = 60 * 60 * 24 # 24 hours CELERY_ENABLED = False +WEBHOOKS_ENABLED = False # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/settings/testing.py b/settings/testing.py index c3fd878d..c20da9eb 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -19,6 +19,7 @@ from .development import * SKIP_SOUTH_TESTS = True SOUTH_TESTS_MIGRATE = False CELERY_ALWAYS_EAGER = True +CELERY_ENABLED = False MEDIA_ROOT = "/tmp" diff --git a/taiga/base/filters.py b/taiga/base/filters.py index f596c409..7be6fcbd 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -204,6 +204,52 @@ class IsProjectMemberFilterBackend(FilterBackend): return super().filter_queryset(request, queryset.distinct(), view) +class BaseIsProjectAdminFilterBackend(object): + def get_project_ids(self, request, view): + project_id = None + if hasattr(view, "filter_fields") and "project" in view.filter_fields: + project_id = request.QUERY_PARAMS.get("project", None) + + if request.user.is_authenticated() and request.user.is_superuser: + return None + + if not request.user.is_authenticated(): + return [] + + memberships_qs = Membership.objects.filter(user=request.user, is_owner=True) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + + projects_list = [membership.project_id for membership in memberships_qs] + + return projects_list + + +class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(project_id__in=project_ids) + + return super().filter_queryset(request, queryset.distinct(), view) + + +class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(webhook__project_id__in=project_ids) + + return super().filter_queryset(request, queryset, view) + class TagsFilter(FilterBackend): def __init__(self, filter_name='tags'): diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index 5cb5ace3..02f25d11 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -31,6 +31,13 @@ def get_typename_for_model_class(model:object, for_concrete_model=True) -> str: return "{0}.{1}".format(model._meta.app_label, model._meta.model_name) +def get_typename_for_model_instance(model_instance): + """ + Get content type tuple from model instance. + """ + ct = ContentType.objects.get_for_model(model_instance) + return ".".join([ct.app_label, ct.model]) + def reload_attribute(model_instance, attr_name): """Fetch the stored value of a model instance attribute. diff --git a/taiga/events/events.py b/taiga/events/events.py index f1d053af..d04fdd61 100644 --- a/taiga/events/events.py +++ b/taiga/events/events.py @@ -18,6 +18,7 @@ import collections from django.contrib.contenttypes.models import ContentType from taiga.base.utils import json +from taiga.base.utils.db import get_typename_for_model_instance from . import middleware as mw from . import backends @@ -32,14 +33,6 @@ watched_types = set([ ]) -def _get_type_for_model(model_instance): - """ - Get content type tuple from model instance. - """ - ct = ContentType.objects.get_for_model(model_instance) - return ".".join([ct.app_label, ct.model]) - - def emit_event(data:dict, routing_key:str, *, sessionid:str=None, channel:str="events"): if not sessionid: @@ -64,7 +57,7 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events", assert hasattr(obj, "project_id") if not content_type: - content_type = _get_type_for_model(obj) + content_type = get_typename_for_model_instance(obj) projectid = getattr(obj, "project_id") pk = getattr(obj, "pk", None) diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py index 9514fada..9c8c5921 100644 --- a/taiga/events/signal_handlers.py +++ b/taiga/events/signal_handlers.py @@ -17,13 +17,15 @@ from django.db.models import signals from django.dispatch import receiver +from taiga.base.utils.db import get_typename_for_model_instance + from . import middleware as mw from . import events def on_save_any_model(sender, instance, created, **kwargs): # Ignore any object that can not have project_id - content_type = events._get_type_for_model(instance) + content_type = get_typename_for_model_instance(instance) # Ignore any other events if content_type not in events.watched_types: @@ -39,7 +41,7 @@ def on_save_any_model(sender, instance, created, **kwargs): def on_delete_any_model(sender, instance, **kwargs): # Ignore any object that can not have project_id - content_type = events._get_type_for_model(instance) + content_type = get_typename_for_model_instance(instance) # Ignore any other changes if content_type not in events.watched_types: diff --git a/taiga/routers.py b/taiga/routers.py index 98f6a86c..7ca20aa6 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -90,6 +90,10 @@ router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-att router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments") router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") +# Webhooks +from taiga.webhooks.api import WebhookViewSet, WebhookLogViewSet +router.register(r"webhooks", WebhookViewSet, base_name="webhooks") +router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") # History & Components from taiga.projects.history.api import UserStoryHistory diff --git a/taiga/webhooks/__init__.py b/taiga/webhooks/__init__.py new file mode 100644 index 00000000..4f9173d3 --- /dev/null +++ b/taiga/webhooks/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +default_app_config = "taiga.webhooks.apps.WebhooksAppConfig" diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py new file mode 100644 index 00000000..8fb952eb --- /dev/null +++ b/taiga/webhooks/api.py @@ -0,0 +1,65 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +from django.shortcuts import get_object_or_404 + +from rest_framework.response import Response + +from taiga.base import filters +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.decorators import detail_route + +from . import models +from . import serializers +from . import permissions +from . import tasks + + +class WebhookViewSet(ModelCrudViewSet): + model = models.Webhook + serializer_class = serializers.WebhookSerializer + permission_classes = (permissions.WebhookPermission,) + filter_backends = (filters.IsProjectAdminFilterBackend,) + filter_fields = ("project",) + + @detail_route(methods=["POST"]) + def test(self, request, pk=None): + webhook = self.get_object() + self.check_permissions(request, 'test', webhook) + + tasks.test_webhook(webhook.id, webhook.url, webhook.key) + + return Response() + +class WebhookLogViewSet(ModelListViewSet): + model = models.WebhookLog + serializer_class = serializers.WebhookLogSerializer + permission_classes = (permissions.WebhookLogPermission,) + filter_backends = (filters.IsProjectAdminFromWebhookLogFilterBackend,) + filter_fields = ("webhook",) + + @detail_route(methods=["POST"]) + def resend(self, request, pk=None): + webhooklog = self.get_object() + self.check_permissions(request, 'resend', webhooklog) + + webhook = webhooklog.webhook + + tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + + return Response() diff --git a/taiga/webhooks/apps.py b/taiga/webhooks/apps.py new file mode 100644 index 00000000..5ae2ac20 --- /dev/null +++ b/taiga/webhooks/apps.py @@ -0,0 +1,37 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.apps import AppConfig +from django.db.models import signals + +from . import signal_handlers as handlers +from taiga.projects.history.models import HistoryEntry + + +def connect_webhooks_signals(): + signals.post_save.connect(handlers.on_new_history_entry, sender=HistoryEntry, dispatch_uid="webhooks") + + +def disconnect_webhooks_signals(): + signals.post_save.disconnect(dispatch_uid="webhooks") + + +class WebhooksAppConfig(AppConfig): + name = "taiga.webhooks" + verbose_name = "Webhooks App Config" + + def ready(self): + connect_webhooks_signals() diff --git a/taiga/webhooks/migrations/0001_initial.py b/taiga/webhooks/migrations/0001_initial.py new file mode 100644 index 00000000..79ec8ba9 --- /dev/null +++ b/taiga/webhooks/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('key', models.TextField(verbose_name='secret key')), + ('project', models.ForeignKey(related_name='webhooks', to='projects.Project')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='WebhookLog', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('status', models.IntegerField(verbose_name='Status code')), + ('request_data', django_pgjson.fields.JsonField(verbose_name='Request data')), + ('response_data', models.TextField(verbose_name='Response data')), + ('webhook', models.ForeignKey(related_name='logs', to='webhooks.Webhook')), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/webhooks/migrations/__init__.py b/taiga/webhooks/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/webhooks/models.py b/taiga/webhooks/models.py new file mode 100644 index 00000000..af802535 --- /dev/null +++ b/taiga/webhooks/models.py @@ -0,0 +1,36 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from django_pgjson.fields import JsonField + + +class Webhook(models.Model): + project = models.ForeignKey("projects.Project", null=False, blank=False, + related_name="webhooks") + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + key = models.TextField(null=False, blank=False, verbose_name=_("secret key")) + + +class WebhookLog(models.Model): + webhook = models.ForeignKey(Webhook, null=False, blank=False, + related_name="logs") + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + status = models.IntegerField(null=False, blank=False, verbose_name=_("Status code")) + request_data = JsonField(null=False, blank=False, verbose_name=_("Request data")) + response_data = models.TextField(null=False, blank=False, verbose_name=_("Response data")) diff --git a/taiga/webhooks/permissions.py b/taiga/webhooks/permissions.py new file mode 100644 index 00000000..7bc58ef6 --- /dev/null +++ b/taiga/webhooks/permissions.py @@ -0,0 +1,40 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api.permissions import (TaigaResourcePermission, IsProjectOwner, + AllowAny, PermissionComponent) + +from taiga.permissions.service import is_project_owner + + +class IsWebhookProjectOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return is_project_owner(request.user, obj.webhook.project) + + +class WebhookPermission(TaigaResourcePermission): + retrieve_perms = IsProjectOwner() + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + test_perms = IsProjectOwner() + + +class WebhookLogPermission(TaigaResourcePermission): + retrieve_perms = IsWebhookProjectOwner() + list_perms = AllowAny() + resend_perms = IsWebhookProjectOwner() diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py new file mode 100644 index 00000000..388e4d5f --- /dev/null +++ b/taiga/webhooks/serializers.py @@ -0,0 +1,131 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework import serializers + +from taiga.base.serializers import TagsField, PgArrayField, JsonField + +from taiga.projects.userstories import models as us_models +from taiga.projects.tasks import models as task_models +from taiga.projects.issues import models as issue_models +from taiga.projects.milestones import models as milestone_models +from taiga.projects.history import models as history_models +from taiga.projects.wiki import models as wiki_models + +from .models import Webhook, WebhookLog + + +class HistoryDiffField(serializers.Field): + def to_native(self, obj): + return {key: {"from": value[0], "to": value[1]} for key, value in obj.items()} + + +class WebhookSerializer(serializers.ModelSerializer): + class Meta: + model = Webhook + +class WebhookLogSerializer(serializers.ModelSerializer): + request_data = JsonField() + + class Meta: + model = WebhookLog + + +class UserSerializer(serializers.Serializer): + id = serializers.SerializerMethodField("get_pk") + name = serializers.SerializerMethodField("get_name") + + def get_pk(self, obj): + return obj.pk + + def get_name(self, obj): + return obj.full_name + +class PointSerializer(serializers.Serializer): + id = serializers.SerializerMethodField("get_pk") + name = serializers.SerializerMethodField("get_name") + value = serializers.SerializerMethodField("get_value") + + def get_pk(self, obj): + return obj.pk + + def get_name(self, obj): + return obj.name + + def get_value(self, obj): + return obj.value + + +class UserStorySerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + external_reference = PgArrayField(required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + points = PointSerializer(many=True) + + class Meta: + model = us_models.UserStory + exclude = ("backlog_order", "sprint_order", "kanban_order", "version") + + +class TaskSerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = task_models.Task + + +class IssueSerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = issue_models.Issue + + +class WikiPageSerializer(serializers.ModelSerializer): + owner = UserSerializer() + last_modifier = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = wiki_models.WikiPage + exclude = ("watchers", "version") + + +class MilestoneSerializer(serializers.ModelSerializer): + owner = UserSerializer() + + class Meta: + model = milestone_models.Milestone + exclude = ("order", "watchers") + + +class HistoryEntrySerializer(serializers.ModelSerializer): + diff = HistoryDiffField() + snapshot = JsonField() + values = JsonField() + user = JsonField() + delete_comment_user = JsonField() + + class Meta: + model = history_models.HistoryEntry diff --git a/taiga/webhooks/signal_handlers.py b/taiga/webhooks/signal_handlers.py new file mode 100644 index 00000000..0483b145 --- /dev/null +++ b/taiga/webhooks/signal_handlers.py @@ -0,0 +1,65 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings + +from taiga.projects.history import services as history_service +from taiga.projects.history.choices import HistoryType + +from . import tasks + + +def _get_project_webhooks(project): + webhooks = [] + for webhook in project.webhooks.all(): + webhooks.append({ + "id": webhook.pk, + "url": webhook.url, + "key": webhook.key, + }) + return webhooks + + +def on_new_history_entry(sender, instance, created, **kwargs): + if not settings.WEBHOOKS_ENABLED: + return None + + if instance.is_hidden: + return None + + model = history_service.get_model_from_key(instance.key) + pk = history_service.get_pk_from_key(instance.key) + obj = model.objects.get(pk=pk) + + webhooks = _get_project_webhooks(obj.project) + + if instance.type == HistoryType.create: + task = tasks.create_webhook + extra_args = [] + elif instance.type == HistoryType.change: + task = tasks.change_webhook + extra_args = [instance] + elif instance.type == HistoryType.delete: + task = tasks.delete_webhook + extra_args = [] + + for webhook in webhooks: + args = [webhook["id"], webhook["url"], webhook["key"], obj] + extra_args + + if settings.CELERY_ENABLED: + task.delay(*args) + else: + task(*args) diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py new file mode 100644 index 00000000..652b9f56 --- /dev/null +++ b/taiga/webhooks/tasks.py @@ -0,0 +1,125 @@ +# Copyright (C) 2013 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import hmac +import hashlib +import requests +from requests.exceptions import RequestException + +from rest_framework.renderers import UnicodeJSONRenderer + +from taiga.base.utils.db import get_typename_for_model_instance +from taiga.celery import app + +from .serializers import (UserStorySerializer, IssueSerializer, TaskSerializer, + WikiPageSerializer, MilestoneSerializer, + HistoryEntrySerializer) +from .models import WebhookLog + + +def _serialize(obj): + content_type = get_typename_for_model_instance(obj) + + if content_type == "userstories.userstory": + return UserStorySerializer(obj).data + elif content_type == "issues.issue": + return IssueSerializer(obj).data + elif content_type == "tasks.task": + return TaskSerializer(obj).data + elif content_type == "wiki.wikipage": + return WikiPageSerializer(obj).data + elif content_type == "milestones.milestone": + return MilestoneSerializer(obj).data + elif content_type == "history.historyentry": + return HistoryEntrySerializer(obj).data + + +def _get_type(obj): + content_type = get_typename_for_model_instance(obj) + return content_type.split(".")[1] + + +def _generate_signature(data, key): + mac = hmac.new(key.encode("utf-8"), msg=data, digestmod=hashlib.sha1) + return mac.hexdigest() + + +def _send_request(webhook_id, url, key, data): + serialized_data = UnicodeJSONRenderer().render(data) + signature = _generate_signature(serialized_data, key) + headers = { + "X-TAIGA-WEBHOOK-SIGNATURE": signature, + } + try: + response = requests.post(url, data=serialized_data, headers=headers) + WebhookLog.objects.create(webhook_id=webhook_id, url=url, + status=response.status_code, + request_data=data, + response_data=response.content) + except RequestException: + WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=0, + request_data=data, + response_data="error-in-request") + + ids = [webhook_log.id for webhook_log in WebhookLog.objects.filter(webhook_id=webhook_id).order_by("-id")[10:]] + WebhookLog.objects.filter(id__in=ids).delete() + + +@app.task +def change_webhook(webhook_id, url, key, obj, change): + data = {} + data['data'] = _serialize(obj) + data['action'] = "change" + data['type'] = _get_type(obj) + data['change'] = _serialize(change) + + _send_request(webhook_id, url, key, data) + + +@app.task +def create_webhook(webhook_id, url, key, obj): + data = {} + data['data'] = _serialize(obj) + data['action'] = "create" + data['type'] = _get_type(obj) + + _send_request(webhook_id, url, key, data) + + +@app.task +def delete_webhook(webhook_id, url, key, obj): + data = {} + data['data'] = _serialize(obj) + data['action'] = "delete" + data['type'] = _get_type(obj) + + _send_request(webhook_id, url, key, data) + + +@app.task +def resend_webhook(webhook_id, url, key, data): + _send_request(webhook_id, url, key, data) + + +@app.task +def test_webhook(webhook_id, url, key): + data = {} + data['data'] = {"test": "test"} + data['action'] = "test" + data['type'] = "test" + + _send_request(webhook_id, url, key, data) + diff --git a/tests/factories.py b/tests/factories.py index e54a3ccb..9c96224e 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -197,6 +197,28 @@ class InvitationFactory(Factory): email = factory.Sequence(lambda n: "user{}@email.com".format(n)) +class WebhookFactory(Factory): + class Meta: + model = "webhooks.Webhook" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + url = "http://localhost:8080/test" + key = "factory-key" + + +class WebhookLogFactory(Factory): + class Meta: + model = "webhooks.WebhookLog" + strategy = factory.CREATE_STRATEGY + + webhook = factory.SubFactory("tests.factories.WebhookFactory") + url = "http://localhost:8080/test" + status = "200" + request_data = "test-request" + response_data = "test-response" + + class StorageEntryFactory(Factory): class Meta: model = "userstorage.StorageEntry" diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py new file mode 100644 index 00000000..ab5cad17 --- /dev/null +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -0,0 +1,307 @@ +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.webhooks.serializers import WebhookSerializer +from taiga.webhooks.models import Webhook +from taiga.webhooks import tasks + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + + m.project1 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + f.MembershipFactory(project=m.project1, + user=m.project_owner, + is_owner=True) + + m.webhook1 = f.WebhookFactory(project=m.project1) + m.webhooklog1 = f.WebhookLogFactory(webhook=m.webhook1) + m.webhook2 = f.WebhookFactory(project=m.project2) + m.webhooklog2 = f.WebhookLogFactory(webhook=m.webhook2) + + return m + + +def test_webhook_retrieve(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhook_update(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + webhook_data = WebhookSerializer(data.webhook1).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url1, webhook_data, users) + assert results == [401, 403, 200] + + webhook_data = WebhookSerializer(data.webhook2).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url2, webhook_data, users) + assert results == [401, 403, 403] + + +def test_webhook_delete(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [401, 403, 204] + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhook_list(client, data): + url = reverse('webhooks-list') + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 1 + assert response.status_code == 200 + + +def test_webhook_create(client, data): + url = reverse('webhooks-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + create_data = json.dumps({ + "url": "http://test.com", + "key": "test", + "project": data.project1.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 201] + + create_data = json.dumps({ + "url": "http://test.com", + "key": "test", + "project": data.project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 403] + + +def test_webhook_patch(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url1, patch_data, users) + assert results == [401, 403, 200] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url2, patch_data, users) + assert results == [401, 403, 403] + + +def test_webhook_action_test(client, data): + url1 = reverse('webhooks-test', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-test', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [404, 404, 200] + assert _send_request_mock.called == True + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [404, 404, 404] + assert _send_request_mock.called == False + + +def test_webhooklogs_list(client, data): + url = reverse('webhooklogs-list') + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 1 + assert response.status_code == 200 + + +def test_webhooklogs_retrieve(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhooklogs_create(client, data): + url1 = reverse('webhooklogs-list') + url2 = reverse('webhooklogs-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_delete(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_update(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'put', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'put', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_action_resend(client, data): + url1 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [404, 404, 200] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [404, 404, 404] diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py new file mode 100644 index 00000000..0b3b32f0 --- /dev/null +++ b/tests/integration/test_webhooks.py @@ -0,0 +1,92 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +from unittest.mock import patch + +from .. import factories as f + +from taiga.projects.history import services + +pytestmark = pytest.mark.django_db + + +def test_new_object_with_one_webhook(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + for obj in objects: + with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert create_webhook_mock.call_count == 1 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner) + assert change_webhook_mock.call_count == 0 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert change_webhook_mock.call_count == 1 + + for obj in objects: + with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert delete_webhook_mock.call_count == 1 + + +def test_new_object_with_two_webhook(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + for obj in objects: + with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert create_webhook_mock.call_count == 2 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert change_webhook_mock.call_count == 2 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner) + assert change_webhook_mock.call_count == 0 + + for obj in objects: + with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert delete_webhook_mock.call_count == 2