Repository: saleor/saleor

Issue:
MAX_CHECKOUT_LINE_QUANTITY by pass while placing order.
### What I'm trying to achieve
…
MAX_CHECKOUT_LINE_QUANTITY = 1 but when click on add to cart multiple times it still adds number of quantity & also validation not working while click on placeorder.


Discussion:

Resolving patch:
diff --git a/saleor/checkout/forms.py b/saleor/checkout/forms.py
--- a/saleor/checkout/forms.py
+++ b/saleor/checkout/forms.py
@@ -1,5 +1,6 @@
 """Checkout-related forms and fields."""
 from datetime import date
+from typing import Any
 
 from django import forms
 from django.conf import settings
@@ -44,6 +45,9 @@ class AddToCheckoutForm(forms.Form):
         'empty-stock': pgettext_lazy(
             'Add to checkout form error',
             'Sorry. This product is currently out of stock.'),
+        'variant-too-many-in-checkout': pgettext_lazy(
+            'Add to checkout form error',
+            'Sorry. You can\'t add more than %d times this item.'),
         'variant-does-not-exists': pgettext_lazy(
             'Add to checkout form error', 'Oops. We could not find that product.'),
         'insufficient-stock': npgettext_lazy(
@@ -57,6 +61,9 @@ def __init__(self, *args, **kwargs):
         self.taxes = kwargs.pop('taxes', {})
         super().__init__(*args, **kwargs)
 
+    def add_error_i18n(self, field, error_name, fmt: Any = tuple()):
+        self.add_error(field, self.error_messages[error_name] % fmt)
+
     def clean(self):
         """Clean the form.
 
@@ -70,22 +77,28 @@ def clean(self):
         try:
             variant = self.get_variant(cleaned_data)
         except ObjectDoesNotExist:
-            msg = self.error_messages['variant-does-not-exists']
-            self.add_error(NON_FIELD_ERRORS, msg)
+            self.add_error_i18n(NON_FIELD_ERRORS, 'variant-does-not-exists')
         else:
             line = self.checkout.get_line(variant)
             used_quantity = line.quantity if line else 0
             new_quantity = quantity + used_quantity
+
+            if new_quantity > settings.MAX_CHECKOUT_LINE_QUANTITY:
+                self.add_error_i18n(
+                    'quantity',
+                    'variant-too-many-in-checkout',
+                    settings.MAX_CHECKOUT_LINE_QUANTITY)
+                return cleaned_data
+
             try:
                 variant.check_quantity(new_quantity)
             except InsufficientStock as e:
                 remaining = e.item.quantity_available - used_quantity
                 if remaining:
-                    msg = self.error_messages['insufficient-stock']
-                    self.add_error('quantity', msg % remaining)
+                    self.add_error_i18n(
+                        'quantity', 'insufficient-stock', remaining)
                 else:
-                    msg = self.error_messages['empty-stock']
-                    self.add_error('quantity', msg)
+                    self.add_error_i18n('quantity', 'empty-stock')
         return cleaned_data
 
     def save(self):
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -1,6 +1,7 @@
 from datetime import date
 
 import graphene
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.db import transaction
 
@@ -63,6 +64,10 @@ def clean_shipping_method(
 def check_lines_quantity(variants, quantities):
     """Check if stock is sufficient for each line in the list of dicts."""
     for variant, quantity in zip(variants, quantities):
+        if quantity > settings.MAX_CHECKOUT_LINE_QUANTITY:
+            raise ValidationError({
+                'quantity': 'Cannot add more than %d times this item.'
+                            '' % settings.MAX_CHECKOUT_LINE_QUANTITY})
         try:
             variant.check_quantity(quantity)
         except InsufficientStock as e:


Test patch:
diff --git a/tests/api/test_checkout.py b/tests/api/test_checkout.py
--- a/tests/api/test_checkout.py
+++ b/tests/api/test_checkout.py
@@ -70,6 +70,29 @@ def test_checkout_create(api_client, variant, graphql_address_data):
     assert new_checkout.shipping_address.city == shipping_address['city'].upper()
 
 
+def test_checkout_create_cannot_add_too_many_quantities(
+        api_client, variant, graphql_address_data):
+
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.id)
+    test_email = 'test@example.com'
+    shipping_address = graphql_address_data
+    variables = {
+        'checkoutInput': {
+            'lines': [{
+                'quantity': 51,
+                'variantId': variant_id}],
+            'email': test_email,
+            'shippingAddress': shipping_address}}
+    assert not Checkout.objects.exists()
+    response = api_client.post_graphql(MUTATION_CHECKOUT_CREATE, variables)
+    content = get_graphql_content(response)['data']['checkoutCreate']
+    assert content['errors']
+    assert content['errors'] == [{
+        'field': 'quantity',
+        'message': 'Cannot add more than 50 times this item.'}]
+
+
+
 def test_checkout_create_reuse_checkout(checkout, user_api_client, variant):
     # assign user to the checkout
     checkout.user = user_api_client.user
@@ -360,6 +383,25 @@ def test_checkout_lines_add(user_api_client, checkout_with_item, variant):
     assert line.quantity == 1
 
 
+def test_checkout_lines_add_too_many(
+        user_api_client, checkout_with_item, variant):
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
+    checkout_id = graphene.Node.to_global_id('Checkout', checkout_with_item.pk)
+
+    variables = {
+        'checkoutId': checkout_id,
+        'lines': [{
+            'variantId': variant_id,
+            'quantity': 51}]}
+    response = user_api_client.post_graphql(
+        MUTATION_CHECKOUT_LINES_ADD, variables)
+    content = get_graphql_content(response)['data']['checkoutLinesAdd']
+
+    assert content['errors']
+    assert content['errors'] == [{
+        'field': 'quantity',
+        'message': 'Cannot add more than 50 times this item.'}]
+
 def test_checkout_lines_add_empty_checkout(user_api_client, checkout, variant):
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
     checkout_id = graphene.Node.to_global_id('Checkout', checkout.pk)
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -141,6 +141,11 @@ def customer_user(address):  # pylint: disable=W0613
     return user
 
 
+@pytest.fixture()
+def user_checkout(customer_user):
+    return Checkout.objects.get_or_create(user=customer_user)[0]
+
+
 @pytest.fixture
 def request_checkout(checkout, monkeypatch):
     # FIXME: Fixtures should not have any side effects
diff --git a/tests/settings.py b/tests/settings.py
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -34,3 +34,4 @@
 COUNTRIES_ONLY = None
 
 MEDIA_ROOT = None
+MAX_CHECKOUT_LINE_QUANTITY = 50
diff --git a/tests/test_cart.py b/tests/test_cart.py
--- a/tests/test_cart.py
+++ b/tests/test_cart.py
@@ -43,11 +43,6 @@ def anonymous_checkout(db):
     return Checkout.objects.get_or_create(user=None)[0]
 
 
-@pytest.fixture()
-def user_checkout(customer_user):
-    return Checkout.objects.get_or_create(user=customer_user)[0]
-
-
 @pytest.fixture()
 def local_currency(monkeypatch):
     def side_effect(price, currency):
diff --git a/tests/test_product.py b/tests/test_product.py
--- a/tests/test_product.py
+++ b/tests/test_product.py
@@ -8,6 +8,7 @@
 import pytest
 from django.core import serializers
 from django.core.serializers.base import DeserializationError
+from django.http import JsonResponse
 from django.urls import reverse
 from freezegun import freeze_time
 from prices import Money, TaxedMoney, TaxedMoneyRange
@@ -191,16 +192,44 @@ def test_view_invalid_add_to_checkout(client, product, request_checkout):
     assert request_checkout.quantity == 2
 
 
-def test_view_add_to_checkout(client, product, request_checkout_with_item):
-    variant = request_checkout_with_item.lines.get().variant
-    response = client.post(
-        reverse(
-            'product:add-to-checkout',
-            kwargs={'slug': product.get_slug(),
-                    'product_id': product.pk}),
-        {'quantity': 1, 'variant': variant.pk})
-    assert response.status_code == 302
-    assert request_checkout_with_item.quantity == 1
+def test_view_add_to_checkout(authorized_client, product, user_checkout):
+    variant = product.variants.first()
+
+    # Ignore stock
+    variant.track_inventory = False
+    variant.save()
+
+    # Add the variant to the user checkout and retrieve the variant line
+    add_variant_to_checkout(user_checkout, variant)
+    checkout_line = user_checkout.lines.last()
+
+    # Retrieve the test url
+    checkout_url = reverse(
+        'product:add-to-checkout',
+        kwargs={'slug': product.get_slug(),
+                'product_id': product.pk})
+
+    # Attempt to set the quantity to 50
+    response = authorized_client.post(
+        checkout_url,
+        {'quantity': 49, 'variant': variant.pk},
+        HTTP_X_REQUESTED_WITH='XMLHttpRequest')  # type: JsonResponse
+    assert response.status_code == 200
+
+    # Ensure the line quantity was updated to 50
+    checkout_line.refresh_from_db(fields=['quantity'])
+    assert checkout_line.quantity == 50
+
+    # Attempt to increase the quantity to a too high count
+    response = authorized_client.post(
+        checkout_url,
+        {'quantity': 1, 'variant': variant.pk},
+        HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    assert response.status_code == 400
+
+    # Ensure the line quantity was not updated to 51
+    checkout_line.refresh_from_db(fields=['quantity'])
+    assert checkout_line.quantity == 50
 
 
 def test_adding_to_checkout_with_current_user_token(