Repository: kbytesys/django-recaptcha3 (language: python)

Issue:
Expose validated score, action, hostname in Form cleaned_data  
Expose the `score`, `action` and `hostname` that come from the server during validation as the FormField's `cleaned_data` attribute. Emphasise that whilst we *can* make a bot/human decision by setting `score_threshold` or `RECAPTCHA_SCORE_THRESHOLD` , it is perfectly fine to use this module just to retrieve/record the scores for later processing.

Changes to unit test and README to cover these cases.
The raw client-provided token has no real value ending up in the form's `cleaned_data`, and there were no tests broken by removing it.

Additionally, I've made it so that setting `RECAPTCHA_DISABLE` returns a mocked response including  `{score: 0.5}` and running this through the validation logic/decision making, which makes it possible to simulate both success and failure in unit tests by changing the desired `score_threshold`.

To try this out, put this in `requirements.txt`:
```
  git+https://github.com/shuckc/django-recaptcha3@1373e0fca62a144039aed1d01f1fc9de66ed79ba#egg=django-recaptcha3
```



Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index b8fcbaa..24cc003 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ RECAPTCHA_SCORE_THRESHOLD = 0.5
 
 If you have to create the apikey for the domains managed by your django project, you can visit this <a href="https://www.google.com/recaptcha/admin">website</a>.
 
-## Usage 
+## Usage
 ### Form and Widget
 You can simply create a reCaptcha enabled form with the field provided by this app:
 
@@ -53,18 +53,44 @@ class ExampleForm(forms.Form):
     [...]
 ```
 
-You can set the private key on the "private_key" argument of the field contructor if you want to override the one inside
-your configuration.
+Form validation of the ReCaptchaField causes us to verify the token returned from the client against the ReCaptcha servers and populates a dictionary containing the `score`, `action`, `hostname`, and `challenge_ts` fields as the form fields `cleaned_data`:
+
+```python
+    def formview(request):
+        if request.method == "POST":
+            form = ExampleForm(request.POST)
+            if form.is_valid():
+              captcha_score = form.cleaned_data['captcha'].get('score')
+```
+
+If a communication problem occurs, the token supplied by the client is invalid or has expired then a ValidationError is raised.
+
+## Automatic Enforcement
+
+If you want low scores to cause a ValidationError, pass an appropriate `score_threshold` to the `ReCaptchaField`, or set the configuration variable settings.RECAPTCHA_SCORE_THRESHOLD.
+
+The default value for the threshold is 0.0, which allows all successful capture responses through for you to later check the value of `score`.
+
+```python
+from snowpenguin.django.recaptcha3.fields import ReCaptchaField
+
+class ExampleForm(forms.Form):
+    [...]
+    captcha = ReCaptchaField(score_threshold=0.5)
+    [...]
+```
+
+You can also set the private key on the "private_key" argument of the ReCaptchaField contructor if you want to override the one inside your configuration.
 
 ### Templating
 You can use some template tags to simplify the reCaptcha adoption:
- 
+
 * recaptcha_init: add the script tag for reCaptcha api. You have to put this tag somewhere in your "head" element
 * recaptcha_ready: call the execute function when the api script is loaded
-* recaptcha_execute: start the reCaptcha check and set the token from the api in your django forms. Token is valid for 120s, after this time it is automatically regenerated. 
+* recaptcha_execute: start the reCaptcha check and set the token from the api in your django forms. Token is valid for 120s, after this time it is automatically regenerated.
 * recaptcha_key: if you want to use reCaptcha manually in your template, you will need the sitekey (a.k.a. public api key).
   This tag returns a string with the configured public key.
-  
+
 You can use the form as usual.
 
 ### Samples
@@ -175,32 +201,19 @@ You can use the plain javascript, just remember to set the correct value for the
 ```
 
 
-## Settings
-
-If you want to use recaptcha's score you need to adjust the bot score threshold.
-
-django-recaptcha3 can adjust the bot score threshold as follows. The default value for the threshold is 0.
-
-```python
-from snowpenguin.django.recaptcha3.fields import ReCaptchaField
-
-class ExampleForm(forms.Form):
-    [...]
-    captcha = ReCaptchaField(score_threshold=0.5)
-    [...]
-```
-
 ## Testing
 ### Test unit support
-You can't simulate api calls in your test, but you can disable the recaptcha field and let your test works.
-
-Just set the RECAPTCHA_DISABLE env variable in your test:
+You can disable recaptcha field validation in unit tests by setting the RECAPTCHA_DISABLE env variable. This will skip the external call to Recaptca servers, returning a valid field with no data.
 
 ```python
 os.environ['RECAPTCHA_DISABLE'] = 'True'
 ```
+You can use any word in place of "True", the clean function will check only if the variable exists.
 
-Warning: you can use any word in place of "True", the clean function will check only if the variable exists.
+If you set `RECAPTCHA_DISABLE` to be valid json, it will be interpreted as a mock captcha server response allowing you to mock score/hostname/action as required:
+```python
+os.environ['RECAPTCHA_DISABLE'] = json.dumps({'score': 0.4, 'hostname': 'localhost', 'action': 'homepage'})
+```
 
 ### Test unit with recaptcha3 disabled
 ```python
diff --git a/snowpenguin/django/recaptcha3/fields.py b/snowpenguin/django/recaptcha3/fields.py
index 0deed54..b5cdb90 100644
--- a/snowpenguin/django/recaptcha3/fields.py
+++ b/snowpenguin/django/recaptcha3/fields.py
@@ -1,3 +1,4 @@
+import json
 import logging
 import os
 
@@ -25,13 +26,14 @@ def __init__(self, attrs=None, *args, **kwargs):
         super(ReCaptchaField, self).__init__(*args, **kwargs)
 
     def clean(self, values):
-
-        # Disable the check if we run a test unit
+        # Disable the check (and allow empty field value) if we run in a unittest
         if os.environ.get('RECAPTCHA_DISABLE', None) is not None:
-            return values[0]
+            try:
+                return json.loads(os.environ.get('RECAPTCHA_DISABLE', None))
+            except:
+                return {}
 
-        super(ReCaptchaField, self).clean(values[0])
-        response_token = values[0]
+        response_token = super(ReCaptchaField, self).clean(values)
 
         try:
             r = requests.post(
@@ -51,8 +53,8 @@ def clean(self, values):
             )
 
         json_response = r.json()
-
         logger.debug("Recieved response from reCaptcha server: %s", json_response)
+
         if bool(json_response['success']):
             if self._score_threshold is not None and self._score_threshold > json_response['score']:
                 raise ValidationError(
@@ -60,7 +62,7 @@ def clean(self, values):
                     code='score',
                     params={'score': json_response['score']},
                 )
-            return values[0]
+            return json_response
         else:
             if 'error-codes' in json_response:
                 if 'missing-input-secret' in json_response['error-codes'] or \


Test patch:
diff --git a/snowpenguin/django/recaptcha3/tests.py b/snowpenguin/django/recaptcha3/tests.py
index 472a944..3fad417 100644
--- a/snowpenguin/django/recaptcha3/tests.py
+++ b/snowpenguin/django/recaptcha3/tests.py
@@ -1,3 +1,4 @@
+import json
 import os
 import mock
 
@@ -15,10 +16,23 @@ class RecaptchaTestForm(Form):
 class TestRecaptchaForm(TestCase):
     def test_dummy_validation(self):
         os.environ['RECAPTCHA_DISABLE'] = 'True'
+
+        # form should validate without a 'g-recaptcha-response'
         form = RecaptchaTestForm({})
-        self.assertTrue(form.is_valid())
+        s = self.assertTrue(form.is_valid())
+        # NOTE: no mocked response is returned
+        self.assertEqual(form.cleaned_data['recaptcha'], {})
         del os.environ['RECAPTCHA_DISABLE']
 
+    def test_dummy_validation_canfail(self):
+        os.environ['RECAPTCHA_DISABLE'] = json.dumps({'score': 0.4, 'hostname': 'localhost', 'action': 'homepage'})
+        form = RecaptchaTestForm({})
+        # NOTE: score_threshold validation is not performed when disabled
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['recaptcha'].get('score'), 0.4)
+        self.assertEqual(form.cleaned_data['recaptcha'].get('hostname'), 'localhost')
+        self.assertEqual(form.cleaned_data['recaptcha'].get('action'), 'homepage')
+
     @mock.patch('requests.post')
     def test_validate_error_invalid_token(self, requests_post):
 
@@ -48,7 +62,9 @@ def test_validate_success_highter_score(self, requests_post):
 
         recaptcha_response = {
             'success': True,
-            'score': 0.7
+            'score': 0.7,
+            'hostname': 'example.com',
+            'action': 'click'
         }
         requests_post.return_value.json = lambda: recaptcha_response
 
@@ -56,6 +72,9 @@ class RecaptchaTestForm(Form):
             recaptcha = ReCaptchaField(score_threshold=0.4)
         form = RecaptchaTestForm({"g-recaptcha-response": "dummy token"})
         self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['recaptcha'].get('score'), 0.7)
+        self.assertEqual(form.cleaned_data['recaptcha'].get('hostname'), 'example.com')
+        self.assertEqual(form.cleaned_data['recaptcha'].get('action'), 'click')
 
     @mock.patch('requests.post')
     def test_settings_score_threshold(self, requests_post):