Repository: saleor/saleor

Issue:
URGENT: Braintree gateway config not working for live
### What I'm trying to achieve
We found a bug in the plugin braintree configuration that is leading to complete non-functional braintree gateway for production. The error is coming from the sandbox_mode configuration parameter.
It works great for sandbox, as this is the standard config:
https://github.com/mirumee/saleor/blob/master/saleor/payment/gateways/braintree/__init__.py#L100
But it seems like sandbox_mode gets stored in saleor as string!! not as python boolean. We checked it by introducing a print(type)) like this, directly after line 100:
```
    print('Sandbox ? ', sandbox_mode)
    print(type(sandbox_mode))
    if not sandbox_mode:
        environment = braintree_sdk.Environment.Production
```
and it returns: 
```
Sandbox ? false
<class 'str'>
``` 
So here we have the core-issue .. 
We wanted to propose a merge request to fix this issue, but honestly I don't know where to fix it the best. This field should never be allowed to be a string, so it seems to be a core issue. 

## Steps to reproduce the problem
1. Use braintree with sandbox_mode disabled
2. try to create an order





Discussion:
Hey, could you tell me more about this issue you are experiencing? What method did you use to change sandbox mode? Do you use dashboard 1.0 or 2.0.
I've checked this (changing sandbox mode to `True` via Dashboard 1.0) and that's what I get:
```python
In [25]: plugin._cached_config = None                                                                                                                                     

In [26]: plugin._initialize_plugin_configuration()                                                                                                                        

In [27]: plugin.config                                                                                                                                                    
Out[27]: GatewayConfig(gateway_name='Braintree', auto_capture=True, template_path='order/payment/braintree.html', connection_params={'sandbox_mode': False, 'merchant_id': '2', 'public_key': '2327', 'private_key': '22223'}, store_customer=False, require_3d_secure=False)

In [28]: gateway = get_braintree_gateway(**plugin.config.connection_params)                                                                                               

In [29]: gateway.config.environment.__dict__                                                                                                                              
Out[29]: 
{'__name__': 'production',
 '_Environment__server': 'api.braintreegateway.com',
 '_Environment__port': '443',
 'is_ssl': True,
 'ssl_certificate': '/Users/miraszewczyk/.virtualenvs/saleor/lib/python3.7/site-packages/braintree/ssl/api_braintreegateway_com.ca.crt',
 '_Environment__auth_url': 'https://auth.venmo.com',
 '_Environment__graphql_server': 'payments.braintree-api.com',
 '_Environment__graphql_port': '443'}
```


**EDIT:**
Few minutes later and I was able to reproduce this bug ;( I will work on this
@korycins @maarcingebala I think we should discuss in what format we want to keep Boolean values in the configuration. In some places, we keep it simple as boolean and in some other, we keep it as `"false"/"true"` - To avoid any of those mistakes in the future we should decide which version we prefer.
- https://github.com/mirumee/saleor/blob/master/saleor/extensions/plugins/avatax/plugin.py#L453 vs https://github.com/mirumee/saleor/blob/master/saleor/extensions/plugins/avatax/plugin.py#L102

I would suggest normalizing value we get from GraphQL to Boolean if needed to avoid any kind of `xyz == "true"`. What do you think?

Missclick 
I agree about the normalization that you suggest. Plus, it would be great if a developer could get a warning when the configuration is wrong. E.g. when providing boolean inside a string to get a message in the console "Value for the configuration key %s{key} is wrong". 
I have a question in that. When you update these values via graphql it tells you, that it is if type BOOLEAN - so my assumption was, that it gets also saved in the database as boolean - no matter if the Apollo client is sending javascript boolean or not 🧐 
But it seems like I'm wrong 🙃 so where is the error appearing the first time? We would really like to deeply understand this case to fix it on our own the next time ;)

Resolving patch:
diff --git a/saleor/extensions/base_plugin.py b/saleor/extensions/base_plugin.py
--- a/saleor/extensions/base_plugin.py
+++ b/saleor/extensions/base_plugin.py
@@ -6,6 +6,7 @@
 from django_countries.fields import Country
 from prices import Money, MoneyRange, TaxedMoney, TaxedMoneyRange
 
+from . import ConfigurationTypeField
 from .models import PluginConfiguration
 
 if TYPE_CHECKING:
@@ -321,15 +322,24 @@ def get_payment_config(self, previous_value):
     def _update_config_items(
         cls, configuration_to_update: List[dict], current_config: List[dict]
     ):
+        config_structure = (
+            cls.CONFIG_STRUCTURE if cls.CONFIG_STRUCTURE is not None else {}
+        )
         for config_item in current_config:
             for config_item_to_update in configuration_to_update:
-                if config_item["name"] == config_item_to_update.get("name"):
+                config_item_name = config_item_to_update.get("name")
+                if config_item["name"] == config_item_name:
                     new_value = config_item_to_update.get("value")
                     # The frontend can send all configuration fields with
                     # all secrets as **...**, we have to omit all secrets which
                     # weren't modified
                     if new_value == cls.REDACTED_FORM:
                         continue
+                    item_type = config_structure.get(config_item_name, {}).get("type")
+                    if item_type == ConfigurationTypeField.BOOLEAN and not isinstance(
+                        new_value, bool
+                    ):
+                        new_value = new_value.lower() == "true"
                     config_item.update([("value", new_value)])
 
     @classmethod
diff --git a/saleor/extensions/plugins/avatax/plugin.py b/saleor/extensions/plugins/avatax/plugin.py
--- a/saleor/extensions/plugins/avatax/plugin.py
+++ b/saleor/extensions/plugins/avatax/plugin.py
@@ -99,9 +99,9 @@ def _initialize_plugin_configuration(self):
             self.config = AvataxConfiguration(
                 username_or_account=configuration["Username or account"],
                 password_or_license=configuration["Password or license"],
-                use_sandbox=configuration["Use sandbox"] == "true",
+                use_sandbox=configuration["Use sandbox"],
                 company_name=configuration["Company name"],
-                autocommit=configuration["Autocommit"] == "true",
+                autocommit=configuration["Autocommit"],
             )
         else:
             # This should be removed after we drop an Avatax's settings from Django


Test patch:
diff --git a/tests/api/test_extensions.py b/tests/api/test_extensions.py
--- a/tests/api/test_extensions.py
+++ b/tests/api/test_extensions.py
@@ -10,6 +10,23 @@
 
 class PluginSample(BasePlugin):
     PLUGIN_NAME = "PluginSample"
+    CONFIG_STRUCTURE = {
+        "Username": {
+            "type": ConfigurationTypeField.STRING,
+            "help_text": "Username input field",
+            "label": "Username",
+        },
+        "Password": {
+            "type": ConfigurationTypeField.STRING,
+            "help_text": "Password input field",
+            "label": "Password",
+        },
+        "Use sandbox": {
+            "type": ConfigurationTypeField.BOOLEAN,
+            "help_text": "Use sandbox",
+            "label": "Use sandbox",
+        },
+    }
 
     @classmethod
     def get_plugin_configuration(cls, queryset) -> "PluginConfiguration":
@@ -35,6 +52,13 @@ def get_plugin_configuration(cls, queryset) -> "PluginConfiguration":
                     "help_text": "Password input field",
                     "label": "Password",
                 },
+                {
+                    "name": "Use sandbox",
+                    "value": False,
+                    "type": ConfigurationTypeField.BOOLEAN,
+                    "help_text": "Use sandbox",
+                    "label": "Use sandbox",
+                },
             ],
         }
         return PluginConfiguration.objects.create(**defaults)
@@ -90,10 +114,16 @@ def test_query_plugin_configurations(
             configuration_item["type"]
             == plugin_configuration.configuration[index]["type"].upper()
         )
-        assert (
-            configuration_item["value"]
-            == plugin_configuration.configuration[index]["value"]
-        )
+        if configuration_item["type"] == ConfigurationTypeField.CHOICES:
+            assert (
+                configuration_item["value"]
+                == plugin_configuration.configuration[index]["value"]
+            )
+        else:
+            assert (
+                configuration_item["value"]
+                == str(plugin_configuration.configuration[index]["value"]).lower()
+            )
         assert (
             configuration_item["helpText"]
             == plugin_configuration.configuration[index]["help_text"]
@@ -151,21 +181,7 @@ def test_query_plugin_configuration(
     assert configuration_item["label"] == plugin_configuration.configuration[0]["label"]
 
 
-@pytest.mark.parametrize(
-    "active, updated_configuration_item",
-    [
-        (True, {"name": "Username", "value": "user"}),
-        (False, {"name": "Username", "value": "admin@example.com"}),
-    ],
-)
-def test_plugin_configuration_update(
-    staff_api_client,
-    permission_manage_plugins,
-    settings,
-    active,
-    updated_configuration_item,
-):
-    query = """
+PLUGIN_UPDATE_MUTATION = """
         mutation pluginUpdate(
             $id: ID!, $active: Boolean, $configuration: [ConfigurationItemInput]){
             pluginUpdate(
@@ -190,6 +206,23 @@ def test_plugin_configuration_update(
           }
         }
     """
+
+
+@pytest.mark.parametrize(
+    "active, updated_configuration_item",
+    [
+        (True, {"name": "Username", "value": "user"}),
+        (False, {"name": "Username", "value": "admin@example.com"}),
+    ],
+)
+def test_plugin_configuration_update(
+    staff_api_client,
+    permission_manage_plugins,
+    settings,
+    active,
+    updated_configuration_item,
+):
+
     settings.PLUGINS = ["tests.api.test_extensions.PluginSample"]
     manager = get_extensions_manager()
     plugin = manager.get_plugin_configuration(plugin_name="PluginSample")
@@ -201,7 +234,7 @@ def test_plugin_configuration_update(
         "configuration": [updated_configuration_item],
     }
     staff_api_client.user.user_permissions.add(permission_manage_plugins)
-    response = staff_api_client.post_graphql(query, variables)
+    response = staff_api_client.post_graphql(PLUGIN_UPDATE_MUTATION, variables)
     get_graphql_content(response)
 
     plugin.refresh_from_db()
@@ -222,6 +255,34 @@ def test_plugin_configuration_update(
     assert second_configuration_item["label"] == old_configuration[1]["label"]
 
 
+def get_config_value(field_name, configuration):
+    for elem in configuration:
+        if elem["name"] == field_name:
+            return elem["value"]
+
+
+def test_plugin_update_saves_boolean_as_boolean(
+    staff_api_client, permission_manage_plugins, settings
+):
+    settings.PLUGINS = ["tests.api.test_extensions.PluginSample"]
+    manager = get_extensions_manager()
+    plugin = manager.get_plugin_configuration(plugin_name="PluginSample")
+    use_sandbox = get_config_value("Use sandbox", plugin.configuration)
+    plugin_id = graphene.Node.to_global_id("Plugin", plugin.pk)
+    variables = {
+        "id": plugin_id,
+        "active": plugin.active,
+        "configuration": [{"name": "Use sandbox", "value": True}],
+    }
+    staff_api_client.user.user_permissions.add(permission_manage_plugins)
+    response = staff_api_client.post_graphql(PLUGIN_UPDATE_MUTATION, variables)
+    content = get_graphql_content(response)
+    assert len(content["data"]["pluginUpdate"]["errors"]) == 0
+    plugin.refresh_from_db()
+    use_sandbox_new_value = get_config_value("Use sandbox", plugin.configuration)
+    assert type(use_sandbox) == type(use_sandbox_new_value)
+
+
 class Plugin1(BasePlugin):
     PLUGIN_NAME = "Plugin1"
 
diff --git a/tests/dashboard/test_payment.py b/tests/dashboard/test_payment.py
--- a/tests/dashboard/test_payment.py
+++ b/tests/dashboard/test_payment.py
@@ -189,6 +189,12 @@ def test_gateway_configuration_form_json_field_set_to_false(field_name, form_dat
     assert cleaned_data[field_name]["value"] is False
 
 
+def get_config_value(field_name, configuration):
+    for elem in configuration:
+        if elem["name"] == field_name:
+            return elem["value"]
+
+
 def test_form_properly_save_plugin_config(form_data, plugin_configuration):
     assert plugin_configuration.active
     form = GatewayConfigurationForm(BraintreeGatewayPlugin, form_data)
@@ -196,6 +202,9 @@ def test_form_properly_save_plugin_config(form_data, plugin_configuration):
     form.save()
     plugin_configuration.refresh_from_db()
     assert plugin_configuration.active is False
+    assert isinstance(
+        get_config_value("Use sandbox", plugin_configuration.configuration), bool
+    )
 
 
 def test_configure_payment_gateway_properly_handle_form(
diff --git a/tests/extensions/plugins/test_avatax.py b/tests/extensions/plugins/test_avatax.py
--- a/tests/extensions/plugins/test_avatax.py
+++ b/tests/extensions/plugins/test_avatax.py
@@ -19,9 +19,11 @@
 
 @pytest.fixture
 def plugin_configuration(db):
-    plugin_configuration = PluginConfiguration.objects.create(
-        **AvataxPlugin._get_default_configuration()
-    )
+    default_configuration = AvataxPlugin._get_default_configuration()
+    for elem in default_configuration["configuration"]:
+        if elem["name"] == "Use sandbox":
+            elem["value"] = False
+    plugin_configuration = PluginConfiguration.objects.create(**default_configuration)
     config = [
         {"name": "Username or account", "value": "2000134479"},
         {"name": "Password or license", "value": "697932CFCBDE505B"},
diff --git a/tests/extensions/plugins/test_plugins.py b/tests/extensions/plugins/test_plugins.py
new file mode 100644
--- /dev/null
+++ b/tests/extensions/plugins/test_plugins.py
@@ -0,0 +1,83 @@
+import pytest
+
+from saleor.extensions import ConfigurationTypeField
+from saleor.extensions.base_plugin import BasePlugin
+from saleor.extensions.models import PluginConfiguration
+
+
+class PluginSample(BasePlugin):
+    PLUGIN_NAME = "PluginSample"
+    CONFIG_STRUCTURE = {
+        "Username": {
+            "type": ConfigurationTypeField.STRING,
+            "help_text": "Username of Sample account",
+            "label": "Username",
+        },
+        "Use sandbox": {
+            "type": ConfigurationTypeField.BOOLEAN,
+            "help_text": "Select if you want to test this integration",
+            "label": "Use sandbox",
+        },
+    }
+
+    @classmethod
+    def _get_default_configuration(cls):
+        defaults = {
+            "name": cls.PLUGIN_NAME,
+            "description": "",
+            "active": True,
+            "configuration": [
+                {"name": "Username", "value": "admin@example.com"},
+                {"name": "Use sandbox", "value": True},
+            ],
+        }
+        return defaults
+
+
+@pytest.fixture(autouse=True)
+def settings_plugins(settings):
+    settings.PLUGINS = [
+        "saleor.extensions.plugins.avatax.plugin.AvataxPlugin",
+        "saleor.extensions.plugins.vatlayer.plugin.VatlayerPlugin",
+        "saleor.extensions.plugins.webhook.plugin.WebhookPlugin",
+        "saleor.payment.gateways.dummy.plugin.DummyGatewayPlugin",
+        "saleor.payment.gateways.stripe.plugin.StripeGatewayPlugin",
+        "saleor.payment.gateways.braintree.plugin.BraintreeGatewayPlugin",
+        "saleor.payment.gateways.razorpay.plugin.RazorpayGatewayPlugin",
+    ]
+
+
+def get_config_value(field_name, configuration):
+    for elem in configuration:
+        if elem["name"] == field_name:
+            return elem["value"]
+
+
+def test_update_config_items_keeps_bool_value():
+    data_to_update = [
+        {"name": "Username", "value": "new_admin@example.com"},
+        {"name": "Use sandbox", "value": False},
+    ]
+    plugin_sample = PluginSample()
+    plugin_sample._initialize_plugin_configuration()
+    qs = PluginConfiguration.objects.all()
+    configuration = PluginSample.get_plugin_configuration(qs)
+    plugin_sample._update_config_items(data_to_update, configuration.configuration)
+    configuration.save()
+    configuration.refresh_from_db()
+    assert get_config_value("Use sandbox", configuration.configuration) is False
+
+
+def test_update_config_items_convert_to_bool_value():
+    data_to_update = [
+        {"name": "Username", "value": "new_admin@example.com"},
+        {"name": "Use sandbox", "value": "false"},
+    ]
+    plugin_sample = PluginSample()
+    plugin_sample._initialize_plugin_configuration()
+    qs = PluginConfiguration.objects.all()
+    configuration = PluginSample.get_plugin_configuration(qs)
+    plugin_sample._update_config_items(data_to_update, configuration.configuration)
+    configuration.save()
+    configuration.refresh_from_db()
+    assert get_config_value("Use sandbox", configuration.configuration) is False