Browse Source

feat(api): Move accept project transfer to api endpoint (#7785)

Jess MacQueen 7 years ago
parent
commit
c77aaa2df0

+ 124 - 0
src/sentry/api/endpoints/accept_project_transfer.py

@@ -0,0 +1,124 @@
+from __future__ import absolute_import
+
+from django.http import Http404
+from django.utils.encoding import force_str
+from django.core.signing import BadSignature, SignatureExpired
+
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from sentry import roles
+from sentry.api.base import Endpoint, SessionAuthentication
+from sentry.api.decorators import sudo_required
+from sentry.api.serializers import serialize
+from sentry.api.serializers.models.organization import DetailedOrganizationSerializer
+from sentry.utils.signing import unsign
+from sentry.models import (
+    AuditLogEntryEvent, OrganizationMember, Organization, OrganizationStatus, Team, Project
+)
+
+
+class InvalidPayload(Exception):
+    pass
+
+
+class AcceptProjectTransferEndpoint(Endpoint):
+    authentication_classes = (SessionAuthentication, )
+    permission_classes = (IsAuthenticated, )
+
+    def get_validated_data(self, data, user):
+        try:
+            data = unsign(force_str(data))
+        except BadSignature:
+            raise InvalidPayload('Could not approve transfer, please make sure link is valid.')
+        except SignatureExpired:
+            raise InvalidPayload('Project transfer link has expired.')
+
+        if data['user_id'] != user.id:
+            raise InvalidPayload('Invalid permissions')
+
+        try:
+            project = Project.objects.get(
+                id=data['project_id'], organization_id=data['from_organization_id'],
+            )
+        except Project.DoesNotExist:
+            raise InvalidPayload('Project no longer exists')
+
+        return data, project
+
+    @sudo_required
+    def get(self, request):
+        try:
+            data = request.GET['data']
+        except KeyError:
+            raise Http404
+
+        try:
+            data, project = self.get_validated_data(data, request.user)
+        except InvalidPayload as exc:
+            return Response({'detail': exc.message}, status=400)
+
+        organizations = Organization.objects.filter(
+            status=OrganizationStatus.ACTIVE,
+            id__in=OrganizationMember.objects.filter(
+                user=request.user,
+                role=roles.get_top_dog().id,
+            ).values_list('organization_id', flat=True),
+        )
+
+        return Response({
+            'organizations': serialize(list(organizations), request.user, DetailedOrganizationSerializer()),
+            'project': {
+                'slug': project.slug,
+                'id': project.id,
+            }
+        })
+
+    @sudo_required
+    def post(self, request):
+        try:
+            data = request.DATA['data']
+        except KeyError:
+            raise Http404
+
+        try:
+            data, project = self.get_validated_data(data, request.user)
+        except InvalidPayload as exc:
+            return Response({'detail': exc.message}, status=400)
+
+        transaction_id = data['transaction_id']
+
+        team_id = request.DATA.get('team')
+        if team_id is None:
+            return Response({'detail': 'Chose a team to transfer project to'}, status=400)
+
+        try:
+            team = Team.objects.get(
+                id=team_id,
+            )
+        except Team.DoesNotExist:
+            return Response({'detail': 'Invalid team'}, status=400)
+
+        # check if user is an owner of the team's org
+        is_team_org_owner = OrganizationMember.objects.filter(
+            user__is_active=True,
+            user=request.user,
+            role=roles.get_top_dog().id,
+            organization_id=team.organization_id,
+        ).exists()
+
+        if not is_team_org_owner:
+            return Response({'detail': 'Invalid team'}, status=400)
+
+        project.transfer_to(team)
+
+        self.create_audit_entry(
+            request=request,
+            organization=project.organization,
+            target_object=project.id,
+            event=AuditLogEntryEvent.PROJECT_ACCEPT_TRANSFER,
+            data=project.get_audit_log_data(),
+            transaction_id=transaction_id,
+        )
+
+        return Response(status=204)

+ 5 - 0
src/sentry/api/urls.py

@@ -2,6 +2,7 @@ from __future__ import absolute_import, print_function
 
 from django.conf.urls import include, patterns, url
 
+from .endpoints.accept_project_transfer import AcceptProjectTransferEndpoint
 from .endpoints.api_applications import ApiApplicationsEndpoint
 from .endpoints.api_application_details import ApiApplicationDetailsEndpoint
 from .endpoints.api_authorizations import ApiAuthorizationsEndpoint
@@ -201,6 +202,10 @@ urlpatterns = patterns(
     url(r'^broadcasts/$', BroadcastIndexEndpoint.as_view(),
         name='sentry-api-0-broadcast-index'),
 
+    # Project transfer
+    url(r'^accept-transfer/$', AcceptProjectTransferEndpoint.as_view(),
+        name='sentry-api-0-accept-project-transfer'),
+
     # Users
     url(r'^users/$', UserIndexEndpoint.as_view(), name='sentry-api-0-user-index'),
     url(

+ 122 - 0
tests/sentry/api/endpoints/test_accept_project_transfer.py

@@ -0,0 +1,122 @@
+from __future__ import absolute_import
+
+from uuid import uuid4
+from six.moves.urllib.parse import urlencode
+from django.core.urlresolvers import reverse
+from sentry.utils.signing import sign
+from sentry.models import Project
+
+from sentry.testutils import APITestCase, PermissionTestCase
+
+
+class AcceptTransferProjectPermissionTest(PermissionTestCase):
+    def setUp(self):
+        super(AcceptTransferProjectPermissionTest, self).setUp()
+        self.project = self.create_project(teams=[self.team])
+        self.path = reverse('sentry-api-0-accept-project-transfer')
+
+    def test_team_admin_cannot_load(self):
+        self.assert_team_admin_cannot_access(self.path)
+
+
+class AcceptTransferProjectTest(APITestCase):
+    def setUp(self):
+        super(AcceptTransferProjectTest, self).setUp()
+        self.owner = self.create_user(email='example@example.com', is_superuser=False)
+        self.from_organization = self.create_organization(owner=self.owner)
+        self.to_organization = self.create_organization(owner=self.owner)
+        self.from_team = self.create_team(organization=self.from_organization)
+        self.to_team = self.create_team(organization=self.to_organization)
+        user = self.create_user('admin@example.com')
+        self.member = self.create_member(
+            organization=self.from_organization,
+            user=user,
+            role='admin',
+            teams=[self.from_team],
+        )
+        self.project = self.create_project(name='proj', teams=[self.from_team])
+        self.transaction_id = uuid4().hex
+        self.path = reverse('sentry-api-0-accept-project-transfer')
+
+    def test_requires_authentication(self):
+        response = self.client.get(self.path)
+        assert response.status_code == 403
+        assert response.data == {'detail': 'Authentication credentials were not provided.'}
+
+    def test_handle_incorrect_url_data(self):
+        self.login_as(self.owner)
+        url_data = sign(
+            actor_id=self.member.id,
+            # This is bad data
+            from_organization_id=9999999,
+            project_id=self.project.id,
+            user_id=self.owner.id,
+            transaction_id=self.transaction_id)
+        resp = self.client.get(self.path + '?' + urlencode({'data': url_data}))
+        assert resp.status_code == 400
+        assert resp.data['detail'] == 'Project no longer exists'
+        resp = self.client.get(self.path)
+        assert resp.status_code == 404
+
+    def test_returns_org_options_with_signed_link(self):
+        self.login_as(self.owner)
+        url_data = sign(
+            actor_id=self.member.user_id,
+            from_organization_id=self.from_organization.id,
+            project_id=self.project.id,
+            user_id=self.owner.id,
+            transaction_id=self.transaction_id)
+
+        resp = self.client.get(self.path + '?' + urlencode({'data': url_data}))
+        assert resp.status_code == 200
+        assert resp.data['project']['slug'] == self.project.slug
+        assert resp.data['project']['id'] == self.project.id
+        assert len(resp.data['organizations']) == 2
+        org_slugs = {o['slug'] for o in resp.data['organizations']}
+        assert self.from_organization.slug in org_slugs
+        assert self.to_organization.slug in org_slugs
+
+    def test_transfers_project_to_correct_organization(self):
+        self.login_as(self.owner)
+        url_data = sign(
+            actor_id=self.member.user_id,
+            from_organization_id=self.from_organization.id,
+            project_id=self.project.id,
+            user_id=self.owner.id,
+            transaction_id=self.transaction_id)
+
+        resp = self.client.post(self.path, data={'team': self.to_team.id, 'data': url_data})
+        assert resp.status_code == 204
+        p = Project.objects.get(id=self.project.id)
+        assert p.organization_id == self.to_organization.id
+        assert p.teams.first() == self.to_team
+
+    def test_non_owner_cannot_transfer_project(self):
+        rando_user = self.create_user(email='blipp@bloop.com', is_superuser=False)
+        rando_org = self.create_organization(name='supreme beans')
+
+        self.login_as(rando_user)
+        url_data = sign(
+            actor_id=self.member.user_id,
+            from_organization_id=rando_org.id,
+            project_id=self.project.id,
+            user_id=rando_user.id,
+            transaction_id=self.transaction_id)
+
+        resp = self.client.post(self.path, data={'team': self.to_team.id, 'data': url_data})
+        assert resp.status_code == 400
+        p = Project.objects.get(id=self.project.id)
+        assert p.organization_id == self.from_organization.id
+
+    def test_cannot_transfer_project_twice_from_same_org(self):
+        self.login_as(self.owner)
+        url_data = sign(
+            actor_id=self.member.user_id,
+            from_organization_id=self.from_organization.id,
+            project_id=self.project.id,
+            user_id=self.owner.id,
+            transaction_id=self.transaction_id)
+
+        resp = self.client.post(self.path, data={'team': self.to_team.id, 'data': url_data})
+        resp = self.client.get(self.path + '?' + urlencode({'data': url_data}))
+        assert resp.status_code == 400