diff --git a/edivorce/apps/core/authenticators.py b/edivorce/apps/core/authenticators.py index 48b10f03..00361cc0 100644 --- a/edivorce/apps/core/authenticators.py +++ b/edivorce/apps/core/authenticators.py @@ -1,5 +1,7 @@ from rest_framework import authentication +from edivorce.apps.core.models import BceidUser + class BCeIDAuthentication(authentication.BaseAuthentication): """ @@ -15,3 +17,6 @@ class BCeIDAuthentication(authentication.BaseAuthentication): except: request.user = request._request.user # pylint: disable=protected-access return (request.user, None) + + def get_user(self, pk): + return BceidUser.objects.get(pk=pk) \ No newline at end of file diff --git a/edivorce/apps/core/tests/test_api.py b/edivorce/apps/core/tests/test_api.py index fd54738a..6f90ac10 100644 --- a/edivorce/apps/core/tests/test_api.py +++ b/edivorce/apps/core/tests/test_api.py @@ -1,8 +1,10 @@ import json +from unittest.util import safe_repr from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import modify_settings +from django.test import modify_settings, override_settings from django.urls import reverse +from graphene_django.utils import GraphQLTestCase from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -34,7 +36,7 @@ class APITest(APITestCase): url = reverse('documents') self.assertEqual(Document.objects.count(), 0) - file = self._create_file() + file = _create_file() data = { 'file': file, 'doc_type': 'AAI', @@ -59,7 +61,7 @@ class APITest(APITestCase): def test_post_duplicate_files_not_allowed(self): url = reverse('documents') - file = self._create_file() + file = _create_file() data = { 'file': file, 'doc_type': 'AAI', @@ -80,7 +82,7 @@ class APITest(APITestCase): def test_post_field_validation(self): url = reverse('documents') - file = self._create_file(extension='HEIC') + file = _create_file(extension='HEIC') data = { 'file': file, 'doc_type': 'INVALID', @@ -277,17 +279,11 @@ class APITest(APITestCase): doc_type = self.default_doc_type if not party_code: party_code = self.default_party_code - new_file = self._create_file() + new_file = _create_file() document = Document(file=new_file, bceid_user=self.user, party_code=party_code, doc_type=doc_type) document.save() return document - @staticmethod - def _create_file(extension='jpg'): - num_documents = Document.objects.count() - new_file = SimpleUploadedFile(f'test_file_{num_documents + 1}.{extension}', b'test content') - return new_file - def _response_data_equals_document(self, response_doc, document_object): self.assertEqual(response_doc['doc_type'], document_object.doc_type) self.assertEqual(response_doc['party_code'], document_object.party_code) @@ -296,3 +292,174 @@ class APITest(APITestCase): self.assertEqual(response_doc['rotation'], document_object.rotation) self.assertEqual(response_doc['sort_order'], document_object.sort_order) self.assertEqual(response_doc['file_url'], document_object.get_file_url()) + + +@override_settings(AUTHENTICATION_BACKENDS=('edivorce.apps.core.authenticators.BCeIDAuthentication',)) +@modify_settings(MIDDLEWARE={'remove': 'edivorce.apps.core.middleware.bceid_middleware.BceidMiddleware', }) +class GraphQLAPITest(GraphQLTestCase): + GRAPHQL_URL = reverse('graphql') + + def setUp(self): + self.user = BceidUser.objects.create(user_guid='1234') + self.another_user = BceidUser.objects.create(user_guid='5678') + self.default_doc_type = 'MC' + self.default_party_code = 0 + + def _login(self): + self._client.force_login(self.user) + + def test_not_logged_in(self): + response = self.query('{documents{filename}}') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.query(''' + mutation ($input: DocumentMetaDataInput!) { + updateMetadata(input: $input) { + documents { + filename + } + } + }''', input_data={}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cannot_get_excluded_fields(self): + self._login() + response = self.query('{documents{id file}}') + print(response.content) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertContainsError('Cannot query field "id"', response) + self.assertContainsError('Cannot query field "file"', response) + + def test_must_specify_doctype_partycode(self): + self._login() + response = self.query('{documents{filename}}') + print(response.content) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertContainsError('argument "docType" of type "String!" is required but not provided', response) + self.assertContainsError('argument "partyCode" of type "Int!" is required but not provided', response) + + def test_get_only_returns_user_form_docs(self): + self._login() + doc = self._create_document() + self._create_document(user=self.another_user) + self._create_document(doc_type='AAI') + self._create_document(party_code=2) + + response = self.query(''' + { + documents (docType: "MC", partyCode: 0) { + filename + } + }''') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertResponseNoErrors(response) + + content = json.loads(response.content)['data'] + self.assertEqual(len(content['documents']), 1) + self.assertEqual(content['documents'][0]['filename'], doc.filename) + + def test_update_metadata(self): + doc_1 = self._create_document() + doc_2 = self._create_document() + + input_data = { + "docType": doc_1.doc_type, + "partyCode": doc_1.party_code, + "files": [ + { + 'filename': doc_2.filename, + 'size': doc_2.size, + 'width': 600, + 'height': 800 + }, + { + 'filename': doc_1.filename, + 'size': doc_1.size, + 'rotation': 270 + }, + ] + } + query = ''' + mutation ($input: DocumentMetaDataInput!) { + updateMetadata(input: $input) { + documents { + filename + } + } + } + ''' + self._login() + response = self.query(query, input_data=input_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertResponseNoErrors(response) + + doc_1.refresh_from_db() + doc_2.refresh_from_db() + self.assertEqual(doc_1.sort_order, 2) + self.assertEqual(doc_1.rotation, 270) + self.assertEqual(doc_2.sort_order, 1) + self.assertEqual(doc_2.width, 600) + self.assertEqual(doc_2.height, 800) + + def test_update_metadata_too_few_files(self): + doc_1 = self._create_document() + doc_2 = self._create_document() + + input_data = { + "docType": doc_1.doc_type, + "partyCode": doc_1.party_code, + "files": [ + { + 'filename': doc_2.filename, + 'size': doc_2.size, + 'rotation': 180, + 'width': 600, + 'height': 800 + } + ] + } + query = ''' + mutation ($input: DocumentMetaDataInput!) { + updateMetadata(input: $input) { + documents { + filename + } + } + } + ''' + self._login() + response = self.query(query, input_data=input_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertResponseHasErrors(response) + self.assertContainsError('there must be the same number of files', response) + + def assertContainsError(self, msg, response): + content = json.loads(response.content) + errors = [error['message'] for error in content['errors']] + for error in errors: + if msg in error: + break + else: + error_msgs = "\n".join([safe_repr(error) for error in errors]) + fail_message = f'Message: {safe_repr(msg)}\nNot found in errors:\n{error_msgs}' + self.fail(fail_message) + + def _create_document(self, user=None, doc_type=None, party_code=None): + if not doc_type: + doc_type = self.default_doc_type + if not party_code: + party_code = self.default_party_code + if not user: + user = self.user + new_file = _create_file() + document = Document(file=new_file, bceid_user=user, party_code=party_code, doc_type=doc_type) + document.save() + return document + + +def _create_file(extension='jpg'): + num_documents = Document.objects.count() + new_file = SimpleUploadedFile(f'test_file_{num_documents + 1}.{extension}', b'test content') + return new_file diff --git a/edivorce/apps/core/views/api.py b/edivorce/apps/core/views/api.py index 29d23041..ac78af7a 100644 --- a/edivorce/apps/core/views/api.py +++ b/edivorce/apps/core/views/api.py @@ -1,9 +1,6 @@ import re -import graphene -import graphene_django from django.http import Http404, HttpResponse, HttpResponseGone, HttpResponseNotFound -from graphql import GraphQLError from rest_framework import permissions, status from rest_framework.generics import CreateAPIView, ListAPIView, RetrieveUpdateDestroyAPIView from rest_framework.views import APIView @@ -127,72 +124,3 @@ def _content_type_from_filename(filename): if not content_type: raise TypeError(f'Filetype "{extension}" not supported') return content_type - - -class DocumentType(graphene_django.DjangoObjectType): - file_url = graphene.String(source='get_file_url') - - class Meta: - model = Document - - -class Query(graphene.ObjectType): - documents = graphene.List(DocumentType, doc_type=graphene.String(required=True), party_code=graphene.Int(required=True)) - - def resolve_documents(self, info, **kwargs): - if info.context.user.is_anonymous: - raise GraphQLError('Unauthorized') - q = Document.objects.filter(bceid_user=info.context.user, **kwargs) - return q - - -class DocumentInput(graphene.InputObjectType): - filename = graphene.String(required=True) - size = graphene.Int(required=True) - width = graphene.Int() - height = graphene.Int() - rotation = graphene.Int() - - -class DocumentMetaDataInput(graphene.InputObjectType): - files = graphene.List(DocumentInput, required=True) - doc_type = graphene.String(required=True) - party_code = graphene.Int(required=True) - - -class UpdateMetadata(graphene.Mutation): - class Arguments: - input = DocumentMetaDataInput(required=True) - - documents = graphene.List(DocumentType) - - def mutate(self, info, **kwargs): - if info.context.user.is_anonymous: - raise GraphQLError('Unauthorized') - input_ = kwargs['input'] - documents = Document.objects.filter(bceid_user=info.context.user, doc_type=input_['doc_type'], party_code=input_['party_code']) - - unique_files = [dict(s) for s in set(frozenset(d.items()) for d in input_['files'])] - if documents.count() != len(input_['files']) or documents.count() != len(unique_files): - raise GraphQLError("Invalid input: there must be the same number of files") - - for i, file in enumerate(input_['files']): - try: - doc = documents.get(filename=file['filename'], size=file['size']) - doc.sort_order = i + 1 - doc.width = file.get('width', file.width) - doc.height = file.get('height', file.height) - doc.rotation = file.get('rotation', file.rotation) - if doc.rotation not in [0, 90, 180, 270]: - raise GraphQLError(f"Invalid rotation {doc.rotation}, must be 0, 90, 180, 270") - doc.save() - except Document.DoesNotExist: - raise GraphQLError(f"Couldn't find document '{file['filename']}' with size '{file['size']}'") - return UpdateMetadata(documents=documents.all()) - - -class Mutations(graphene.ObjectType): - update_metadata = UpdateMetadata.Field() - - -graphql_schema = graphene.Schema(query=Query, mutation=Mutations) diff --git a/edivorce/apps/core/views/graphql.py b/edivorce/apps/core/views/graphql.py new file mode 100644 index 00000000..dcbcbe0a --- /dev/null +++ b/edivorce/apps/core/views/graphql.py @@ -0,0 +1,82 @@ +import graphene +import graphene_django +from django.http import HttpResponseForbidden +from graphene_django.views import GraphQLView +from graphql import GraphQLError + +from edivorce.apps.core.models import Document + + +class PrivateGraphQLView(GraphQLView): + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return HttpResponseForbidden() + return super().dispatch(request, *args, **kwargs) + + +class DocumentType(graphene_django.DjangoObjectType): + file_url = graphene.String(source='get_file_url') + + class Meta: + model = Document + exclude = ('id', 'file') + + +class Query(graphene.ObjectType): + documents = graphene.List(DocumentType, doc_type=graphene.String(required=True), party_code=graphene.Int(required=True)) + + def resolve_documents(self, info, **kwargs): + if info.context.user.is_anonymous: + raise GraphQLError('Unauthorized') + q = Document.objects.filter(bceid_user=info.context.user, **kwargs) + return q + + +class DocumentInput(graphene.InputObjectType): + filename = graphene.String(required=True) + size = graphene.Int(required=True) + width = graphene.Int() + height = graphene.Int() + rotation = graphene.Int() + + +class DocumentMetaDataInput(graphene.InputObjectType): + files = graphene.List(DocumentInput, required=True) + doc_type = graphene.String(required=True) + party_code = graphene.Int(required=True) + + +class UpdateMetadata(graphene.Mutation): + class Arguments: + input = DocumentMetaDataInput(required=True) + + documents = graphene.List(DocumentType) + + def mutate(self, info, **kwargs): + input_ = kwargs['input'] + documents = Document.objects.filter(bceid_user=info.context.user, doc_type=input_['doc_type'], party_code=input_['party_code']) + + unique_files = [dict(s) for s in set(frozenset(d.items()) for d in input_['files'])] + if documents.count() != len(input_['files']) or documents.count() != len(unique_files): + raise GraphQLError("Invalid input: there must be the same number of files") + + for i, file in enumerate(input_['files']): + try: + doc = documents.get(filename=file['filename'], size=file['size']) + doc.sort_order = i + 1 + doc.width = file.get('width', doc.width) + doc.height = file.get('height', doc.height) + doc.rotation = file.get('rotation', doc.rotation) + if doc.rotation not in [0, 90, 180, 270]: + raise GraphQLError(f"Invalid rotation {doc.rotation}, must be 0, 90, 180, 270") + doc.save() + except Document.DoesNotExist: + raise GraphQLError(f"Couldn't find document '{file['filename']}' with size '{file['size']}'") + return UpdateMetadata(documents=documents.all()) + + +class Mutations(graphene.ObjectType): + update_metadata = UpdateMetadata.Field() + + +graphql_schema = graphene.Schema(query=Query, mutation=Mutations) diff --git a/edivorce/settings/base.py b/edivorce/settings/base.py index f1660750..b1b5e6e3 100644 --- a/edivorce/settings/base.py +++ b/edivorce/settings/base.py @@ -105,10 +105,6 @@ REST_FRAMEWORK = { ] } -GRAPHENE = { - 'SCHEMA': 'edivorce.apps.core.views.api.graphql_schema' -} - LOGGING = { 'version': 1, diff --git a/edivorce/urls.py b/edivorce/urls.py index c98ef132..ada0d674 100644 --- a/edivorce/urls.py +++ b/edivorce/urls.py @@ -3,9 +3,9 @@ from django.conf.urls import include, url from django.contrib import admin from django.urls import path from django.views.decorators.csrf import csrf_exempt -from graphene_django.views import GraphQLView from .apps.core.views import main +from .apps.core.views.graphql import PrivateGraphQLView, graphql_schema urlpatterns = [] @@ -13,9 +13,9 @@ if settings.ENVIRONMENT in ['localdev', 'dev', 'test', 'minishift']: import debug_toolbar urlpatterns.append(url(r'^__debug__/', include(debug_toolbar.urls)),) urlpatterns.append(url(r'^poc/', include('edivorce.apps.poc.urls'))) - urlpatterns.append(path('api/graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True)))) + urlpatterns.append(path('api/graphql/', csrf_exempt(PrivateGraphQLView.as_view(graphiql=True, schema=graphql_schema)), name='graphql')) else: - urlpatterns.append(path('api/graphql/', csrf_exempt(GraphQLView.as_view(graphiql=False)))) + urlpatterns.append(path('api/graphql/', csrf_exempt(PrivateGraphQLView.as_view(graphiql=False, schema=graphql_schema)), name='graphql')) if settings.ENVIRONMENT in ['localdev', 'minishift']: urlpatterns.append(url(r'^admin/', admin.site.urls))