Repository: saleor/saleor

Issue:
Validate files uploaded in API in a unified way
We need a single function to validate files uploaded in the API. Now we do content type validation in `ProductImageCreate` mutation, but it is missing in other mutations that support upload e.g. creating categories or collections. We should extract validation to a function and reuse it in all places.


Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -18,7 +18,7 @@
 from ...core.types import SeoInput, Upload
 from ...core.utils import clean_seo_fields
 from ..types import Category, Collection, Product, ProductImage, ProductVariant
-from ..utils import attributes_to_hstore
+from ..utils import attributes_to_hstore, validate_image_file
 
 
 class CategoryInput(graphene.InputObjectType):
@@ -54,6 +54,9 @@ def clean_input(cls, info, instance, input, errors):
             parent = cls.get_node_or_error(
                 info, parent_id, errors, field='parent', only_type=Category)
             cleaned_input['parent'] = parent
+        if input.get('background_image'):
+            image_data = info.context.FILES.get(input['background_image'])
+            validate_image_file(cls, image_data, 'background_image', errors)
         clean_seo_fields(cleaned_input)
         return cleaned_input
 
@@ -146,6 +149,9 @@ def clean_input(cls, info, instance, input, errors):
         cleaned_input = super().clean_input(info, instance, input, errors)
         if 'slug' not in cleaned_input and 'name' in cleaned_input:
             cleaned_input['slug'] = slugify(cleaned_input['name'])
+        if input.get('background_image'):
+            image_data = info.context.FILES.get(input['background_image'])
+            validate_image_file(cls, image_data, 'background_image', errors)
         clean_seo_fields(cleaned_input)
         return cleaned_input
 
@@ -651,8 +657,7 @@ def mutate(cls, root, info, input):
         product = cls.get_node_or_error(
             info, input['product'], errors, 'product', only_type=Product)
         image_data = info.context.FILES.get(input['image'])
-        if not image_data.content_type.startswith('image/'):
-            cls.add_error(errors, 'image', 'Invalid file type')
+        validate_image_file(cls, image_data, 'image', errors)
         image = None
         if not errors:
             image = product.images.create(
diff --git a/saleor/graphql/product/utils.py b/saleor/graphql/product/utils.py
--- a/saleor/graphql/product/utils.py
+++ b/saleor/graphql/product/utils.py
@@ -41,3 +41,9 @@ def attributes_to_hstore(attribute_value_input, attributes_queryset):
         attributes_hstore[str(attribute_id)] = str(value_id)
 
     return attributes_hstore
+
+
+def validate_image_file(mutation_cls, file, field_name, errors):
+    """Validate if the file is an image."""
+    if not file.content_type.startswith('image/'):
+        mutation_cls.add_error(errors, field_name, 'Invalid file type')


Test patch:
diff --git a/tests/api/test_category.py b/tests/api/test_category.py
--- a/tests/api/test_category.py
+++ b/tests/api/test_category.py
@@ -4,10 +4,10 @@
 import pytest
 from django.template.defaultfilters import slugify
 from graphql_relay import to_global_id
+from tests.api.utils import get_graphql_content, get_multipart_request_body
+from tests.utils import create_image, create_pdf_file_with_image_ext
 
 from saleor.product.models import Category
-from tests.api.utils import get_graphql_content, get_multipart_request_body
-from tests.utils import create_image
 
 
 def test_category_query(user_api_client, product):
@@ -165,39 +165,40 @@ def test_category_create_mutation_without_background_image(
     assert mock_create_thumbnails.call_count == 0
 
 
-def test_category_update_mutation(
-        monkeypatch, staff_api_client, category, permission_manage_products):
-    query = """
-        mutation($id: ID!, $name: String, $slug: String, $backgroundImage: Upload, $backgroundImageAlt: String, $description: String) {
-            categoryUpdate(
-                id: $id
-                input: {
-                    name: $name
-                    description: $description
-                    backgroundImage: $backgroundImage
-                    backgroundImageAlt: $backgroundImageAlt
-                    slug: $slug
-                }
-            ) {
-                category {
+MUTATION_CATEGORY_UPDATE_MUTATION = """
+    mutation($id: ID!, $name: String, $slug: String, $backgroundImage: Upload, $backgroundImageAlt: String, $description: String) {
+        categoryUpdate(
+            id: $id
+            input: {
+                name: $name
+                description: $description
+                backgroundImage: $backgroundImage
+                backgroundImageAlt: $backgroundImageAlt
+                slug: $slug
+            }
+        ) {
+            category {
+                id
+                name
+                description
+                parent {
                     id
-                    name
-                    description
-                    parent {
-                        id
-                    }
-                    backgroundImage{
-                        alt
-                    }
                 }
-                errors {
-                    field
-                    message
+                backgroundImage{
+                    alt
                 }
             }
+            errors {
+                field
+                message
+            }
         }
+    }
     """
 
+
+def test_category_update_mutation(
+        monkeypatch, staff_api_client, category, permission_manage_products):
     mock_create_thumbnails = Mock(return_value=None)
     monkeypatch.setattr(
         ('saleor.dashboard.category.forms.'
@@ -219,7 +220,8 @@ def test_category_update_mutation(
         'name': category_name, 'description': category_description,
         'backgroundImage': image_name, 'backgroundImageAlt': image_alt,
         'id': category_id, 'slug': category_slug}
-    body = get_multipart_request_body(query, variables, image_file, image_name)
+    body = get_multipart_request_body(
+        MUTATION_CATEGORY_UPDATE_MUTATION, variables, image_file, image_name)
     response = staff_api_client.post_multipart(
         body, permissions=[permission_manage_products])
     content = get_graphql_content(response)
@@ -237,6 +239,28 @@ def test_category_update_mutation(
     assert data['category']['backgroundImage']['alt'] == image_alt
 
 
+def test_category_update_mutation_invalid_background_image(
+        staff_api_client, category, permission_manage_products):
+    image_file, image_name = create_pdf_file_with_image_ext()
+    image_alt = 'Alt text for an image.'
+    variables = {
+        'name': 'new-name',
+        'slug': 'new-slug',
+        'id': to_global_id('Category', category.id),
+        'backgroundImage': image_name,
+        'backgroundImageAlt': image_alt,
+        'isPublished': True}
+    body = get_multipart_request_body(
+        MUTATION_CATEGORY_UPDATE_MUTATION, variables,
+        image_file, image_name)
+    response = staff_api_client.post_multipart(
+        body, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['categoryUpdate']
+    assert data['errors'][0]['field'] == 'backgroundImage'
+    assert data['errors'][0]['message'] == 'Invalid file type'
+
+
 def test_category_update_mutation_without_background_image(
         monkeypatch, staff_api_client, category, permission_manage_products):
     query = """
@@ -305,19 +329,19 @@ def test_category_delete_mutation(
 
 
 LEVELED_CATEGORIES_QUERY = """
-query leveled_categories($level: Int) {
-    categories(level: $level, first: 20) {
-        edges {
-            node {
-                name
-                parent {
+    query leveled_categories($level: Int) {
+        categories(level: $level, first: 20) {
+            edges {
+                node {
                     name
+                    parent {
+                        name
+                    }
                 }
             }
         }
     }
-}
-"""
+    """
 
 
 def test_category_level(user_api_client, category):
@@ -340,16 +364,16 @@ def test_category_level(user_api_client, category):
 
 
 FETCH_CATEGORY_QUERY = """
-query fetchCategory($id: ID!){
-    category(id: $id) {
-        name
-        backgroundImage(size: 120) {
-           url
-           alt
+    query fetchCategory($id: ID!){
+        category(id: $id) {
+            name
+            backgroundImage(size: 120) {
+            url
+            alt
+            }
         }
     }
-}
-"""
+    """
 
 
 def test_category_image_query(user_api_client, non_default_category):
diff --git a/tests/api/test_collection.py b/tests/api/test_collection.py
--- a/tests/api/test_collection.py
+++ b/tests/api/test_collection.py
@@ -1,13 +1,13 @@
+from datetime import date
 from unittest.mock import Mock
 
 import graphene
 import pytest
-from datetime import date
 from django.utils.text import slugify
 from graphql_relay import to_global_id
+from tests.utils import create_image, create_pdf_file_with_image_ext
 
 from saleor.product.models import Collection
-from tests.utils import create_image
 
 from .utils import get_graphql_content, get_multipart_request_body
 
@@ -180,34 +180,33 @@ def test_update_collection(
     assert mock_create_thumbnails.call_count == 0
 
 
-def test_update_collection_with_background_image(
-        monkeypatch, staff_api_client, collection, permission_manage_products):
-    query = """
-        mutation updateCollection(
-            $name: String!, $slug: String!, $id: ID!, $backgroundImage: Upload, $backgroundImageAlt: String, $isPublished: Boolean!) {
-            collectionUpdate(
-                id: $id, input: {
-                    name: $name,
-                    slug: $slug,
-                    backgroundImage: $backgroundImage,
-                    backgroundImageAlt: $backgroundImageAlt,
-                    isPublished: $isPublished
-                }
-            ) {
-                collection {
-                    slug
-                    backgroundImage{
-                        alt
-                    }
-                }
-                errors {
-                    field
-                    message
+MUTATION_UPDATE_COLLECTION_WITH_BACKGROUND_IMAGE = """
+    mutation updateCollection($name: String!, $slug: String!, $id: ID!, $backgroundImage: Upload, $backgroundImageAlt: String, $isPublished: Boolean!) {
+        collectionUpdate(
+            id: $id, input: {
+                name: $name,
+                slug: $slug,
+                backgroundImage: $backgroundImage,
+                backgroundImageAlt: $backgroundImageAlt,
+                isPublished: $isPublished
+            }
+        ) {
+            collection {
+                slug
+                backgroundImage{
+                    alt
                 }
             }
+            errors {
+                field
+                message
+            }
         }
-    """
+    }"""
+
 
+def test_update_collection_with_background_image(
+        monkeypatch, staff_api_client, collection, permission_manage_products):
     mock_create_thumbnails = Mock(return_value=None)
     monkeypatch.setattr(
         ('saleor.dashboard.collection.forms.'
@@ -223,7 +222,9 @@ def test_update_collection_with_background_image(
         'backgroundImage': image_name,
         'backgroundImageAlt': image_alt,
         'isPublished': True}
-    body = get_multipart_request_body(query, variables, image_file, image_name)
+    body = get_multipart_request_body(
+        MUTATION_UPDATE_COLLECTION_WITH_BACKGROUND_IMAGE, variables,
+        image_file, image_name)
     response = staff_api_client.post_multipart(
         body, permissions=[permission_manage_products])
     content = get_graphql_content(response)
@@ -236,6 +237,28 @@ def test_update_collection_with_background_image(
     assert data['collection']['backgroundImage']['alt'] == image_alt
 
 
+def test_update_collection_invalid_background_image(
+        staff_api_client, collection, permission_manage_products):
+    image_file, image_name = create_pdf_file_with_image_ext()
+    image_alt = 'Alt text for an image.'
+    variables = {
+        'name': 'new-name',
+        'slug': 'new-slug',
+        'id': to_global_id('Collection', collection.id),
+        'backgroundImage': image_name,
+        'backgroundImageAlt': image_alt,
+        'isPublished': True}
+    body = get_multipart_request_body(
+        MUTATION_UPDATE_COLLECTION_WITH_BACKGROUND_IMAGE, variables,
+        image_file, image_name)
+    response = staff_api_client.post_multipart(
+        body, permissions=[permission_manage_products])
+    content = get_graphql_content(response)
+    data = content['data']['collectionUpdate']
+    assert data['errors'][0]['field'] == 'backgroundImage'
+    assert data['errors'][0]['message'] == 'Invalid file type'
+
+
 def test_delete_collection(
         staff_api_client, collection, permission_manage_products):
     query = """