From efc07e725efac033c068a330b3953f81ec6a5061 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20NO=C3=8BL?= <tnoel@entrouvert.com>
Date: Wed, 23 Feb 2022 01:23:21 +0100
Subject: [PATCH] misc: add fields verification after tracking code (#59027)

---
 help/fr/api-schema.page        |   9 +--
 tests/admin_pages/test_form.py |  14 +++++
 tests/api/test_formdef.py      |   6 ++
 tests/form_pages/test_all.py   | 106 +++++++++++++++++++++++++++++++++
 tests/test_formdef_import.py   |  18 ++++++
 wcs/admin/forms.py             |  16 +++++
 wcs/formdef.py                 |  16 ++++-
 wcs/forms/root.py              |  39 +++++++++++-
 8 files changed, 217 insertions(+), 7 deletions(-)

diff --git a/help/fr/api-schema.page b/help/fr/api-schema.page
index 4734a8fb8..acb6bd084 100644
--- a/help/fr/api-schema.page
+++ b/help/fr/api-schema.page
@@ -178,10 +178,11 @@ formulaire.
 <code mime="application/json">
 {
     "name": "Newsletter",
-    "only_allow_one": "false",
-    "enable_tracking_codes": "true",
-    "confirmation": "true",
-    "discussion": "false",
+    "only_allow_one": false,
+    "enable_tracking_codes": true,
+    "tracking_code_verify_fields": ["1"],
+    "confirmation": true,
+    "discussion": false,
     "fields": [
         {
             "label": "Nom",
diff --git a/tests/admin_pages/test_form.py b/tests/admin_pages/test_form.py
index 226070d72..82cc9d79c 100644
--- a/tests/admin_pages/test_form.py
+++ b/tests/admin_pages/test_form.py
@@ -245,6 +245,20 @@ def test_forms_edit_tracking_code(pub, formdef):
     resp = resp.forms[0].submit().follow()
     assert FormDef.get(1).drafts_lifespan == '5'
 
+    formdef.fields = [
+        fields.StringField(id='1', label='VerifyString', type='string'),
+        fields.DateField(id='2', label='VerifyDate', type='date'),
+        fields.ItemField(id='3', label='CannotVerify', type='item'),
+    ]
+    formdef.store()
+    resp = resp.click('Tracking Code')
+    assert '<option value="1">VerifyString</option>' in resp
+    assert '<option value="2">VerifyDate</option>' in resp
+    assert 'CannotVerify' not in resp
+    resp.forms[0]['tracking_code_verify_fields$element0'].value = '1'
+    resp = resp.forms[0].submit().follow()
+    assert FormDef.get(1).tracking_code_verify_fields == ['1']
+
 
 def test_forms_edit_captcha(pub, formdef):
     create_superuser(pub)
diff --git a/tests/api/test_formdef.py b/tests/api/test_formdef.py
index 140225fb8..7691ab07e 100644
--- a/tests/api/test_formdef.py
+++ b/tests/api/test_formdef.py
@@ -452,6 +452,8 @@ def test_formdef_schema(pub, access):
 
     formdef.category_id = cat.id
     formdef.workflow_id = workflow.id
+    formdef.enable_tracking_codes = True
+    formdef.tracking_code_verify_fields = ['0']
     formdef.store()
 
     with mock.patch('wcs.qommon.misc.urlopen') as urlopen:
@@ -488,6 +490,7 @@ def test_formdef_schema(pub, access):
     # check schema
     assert set(resp.json.keys()) >= {
         'enable_tracking_codes',
+        'tracking_code_verify_fields',
         'url_name',
         'description',
         'workflow',
@@ -506,6 +509,9 @@ def test_formdef_schema(pub, access):
     }
     assert resp.json['name'] == 'test'
 
+    assert resp.json['enable_tracking_codes'] is True
+    assert resp.json['tracking_code_verify_fields'] == ['0']
+
     # fields checks
     assert resp.json['fields'][0]['label'] == 'foobar'
     assert resp.json['fields'][0]['type'] == 'string'
diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index f485e2c88..6610e8b3e 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -1773,6 +1773,112 @@ def test_form_tracking_code(pub, nocache):
     resp = resp.follow()
 
 
+def test_form_tracking_code_verification(pub, nocache):
+    formdef = create_formdef()
+    formdef.fields = [
+        fields.StringField(id='0', label='string1', required=False),
+        fields.StringField(id='1', label='string2', required=False),
+    ]
+    formdef.enable_tracking_codes = True
+    formdef.tracking_code_verify_fields = ['0', '1']
+    formdef.store()
+
+    resp = get_app(pub).get('/test/')
+    formdef.data_class().wipe()
+    assert '<h3>Tracking code</h3>' in resp.text
+    resp.forms[0]['f0'] = 'foobar1'
+    resp.forms[0]['f1'] = 'foobar2'
+    resp = resp.forms[0].submit('submit')
+    tracking_code = get_displayed_tracking_code(resp)
+    assert tracking_code is not None
+
+    assert formdef.data_class().count() == 1
+    assert formdef.data_class().select()[0].is_draft()
+    assert formdef.data_class().select()[0].tracking_code == tracking_code
+    assert formdef.data_class().select()[0].data['0'] == 'foobar1'
+    assert formdef.data_class().select()[0].data['1'] == 'foobar2'
+    formdata = formdef.data_class().select()[0]
+    formdata_id = formdata.id
+
+    resp = get_app(pub).get('/')
+    resp.forms[0]['code'] = tracking_code
+    resp = resp.forms[0].submit()
+    assert resp.location == 'http://example.net/code/%s/load' % tracking_code
+    resp = resp.follow()
+    assert 'Access rights verification' in resp
+    resp.forms[0]['f0'] = 'foobar1'
+    resp.forms[0]['f1'] = 'foobar2'
+    resp = resp.forms[0].submit('submit')
+    assert resp.location == 'http://example.net/test/%s/' % formdata_id
+    resp = resp.follow()
+    assert resp.location.startswith('http://example.net/test/?mt=')
+    resp = resp.follow()
+
+    # check anonymous user can't get to it from the URL
+    pub.session_manager.session_class.wipe()
+    resp = get_app(pub).get('http://example.net/test/%s/' % formdata_id)
+    assert resp.location.startswith('http://example.net/login')
+    # or logged users that didn't enter the code:
+    create_user(pub)
+    login(get_app(pub), username='foo', password='foo').get(
+        'http://example.net/test/%s/' % formdata_id, status=403
+    )
+
+    # verification failure
+    resp = get_app(pub).get('http://example.net/code/%s/load' % tracking_code)
+    assert 'Access rights verification' in resp
+    resp.forms[0]['f0'] = 'foobar1'  # ok
+    resp.forms[0]['f1'] = 'barfoo2'  # ko
+    resp = resp.forms[0].submit('submit', status=403)
+    assert 'Access rights verification failed' in resp
+
+    # draft with an empty field: do not verify it
+    formdata.data['0'] = None
+    formdata.store()
+    resp = get_app(pub).get('http://example.net/code/%s/load' % tracking_code)
+    assert 'Access rights verification' in resp
+    assert 'f0' not in resp.forms[0].fields
+    assert 'f1' in resp.forms[0].fields
+    resp.forms[0]['f1'] = 'foobar2'
+    resp = resp.forms[0].submit('submit')
+    assert resp.location == 'http://example.net/test/%s/' % formdata_id
+    resp = resp.follow()
+    assert resp.location.startswith('http://example.net/test/?mt=')
+    resp = resp.follow()
+
+    # empty draft: no verification
+    formdata.data['1'] = None
+    formdata.store()
+    resp = get_app(pub).get('http://example.net/code/%s/load' % tracking_code)
+    assert resp.location == 'http://example.net/test/%s/' % formdata_id
+    resp = resp.follow()
+    assert resp.location.startswith('http://example.net/test/?mt=')
+
+    # not a draft: all validation fields are required
+    formdata.status = 'wf-new'
+    formdata.data['0'] = 'foobar1'
+    formdata.store()
+    resp = get_app(pub).get('http://example.net/code/%s/load' % tracking_code)
+    assert 'Access rights verification' in resp
+    assert 'f0' in resp.forms[0].fields
+    assert 'f1' in resp.forms[0].fields
+    resp.forms[0]['f0'] = 'foobar1'
+    resp.forms[0]['f1'] = ''
+    resp = resp.forms[0].submit('submit')
+    assert resp.location == 'http://example.net/test/%s/' % formdata_id
+    resp = resp.follow()
+    assert 'foobar1' in resp.text
+    assert 'form_comment' in resp.text  # user is treated as submitter
+
+    # verification failure
+    resp = get_app(pub).get('http://example.net/code/%s/load' % tracking_code)
+    assert 'Access rights verification' in resp
+    resp.forms[0]['f0'] = 'foobar1'  # ok
+    resp.forms[0]['f1'] = 'not empty'  # ko
+    resp = resp.forms[0].submit('submit', status=403)
+    assert 'Access rights verification failed' in resp
+
+
 def test_form_tracking_code_rate_limit(pub, freezer):
     pub.load_site_options()
     if not pub.site_options.has_section('options'):
diff --git a/tests/test_formdef_import.py b/tests/test_formdef_import.py
index 4ad399e10..dd215e758 100644
--- a/tests/test_formdef_import.py
+++ b/tests/test_formdef_import.py
@@ -884,3 +884,21 @@ def test_import_formdef_root_node_error():
         excinfo.value.msg
         == 'Provided XML file is invalid, it starts with a <wrong_root_node> tag instead of <carddef>'
     )
+
+
+def test_tracking_code_attributes(pub):
+    formdef = FormDef()
+    formdef.name = 'Foo'
+    formdef.url_name = 'foo'
+    formdef.confirmation = True
+    formdef.enable_tracking_codes = True
+    for verify_fields in (['1', '2'], [], None):
+        formdef.tracking_code_verify_fields = verify_fields
+        f2 = assert_xml_import_export_works(formdef)
+        assert f2.enable_tracking_codes == formdef.enable_tracking_codes
+        assert f2.tracking_code_verify_fields == formdef.tracking_code_verify_fields
+        assert f2.confirmation == formdef.confirmation
+        f2 = assert_json_import_export_works(formdef)
+        assert f2.enable_tracking_codes == formdef.enable_tracking_codes
+        assert f2.tracking_code_verify_fields == formdef.tracking_code_verify_fields
+        assert f2.confirmation == formdef.confirmation
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index ac69f5858..1f1f4bf3f 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -297,6 +297,21 @@ class OptionsDirectory(Directory):
             title=_('Enable support for tracking codes'),
             value=self.formdef.enable_tracking_codes,
         )
+        verify_fields = [(None, '---', None)]
+        for field in self.formdef.fields:
+            if field.type in ('string', 'date', 'email'):
+                verify_fields.append((field.id, field.label, field.id))
+        form.add(
+            WidgetList,
+            'tracking_code_verify_fields',
+            title=_('Fields to check after entering the tracking code'),
+            element_type=SingleSelectWidget,
+            value=self.formdef.tracking_code_verify_fields,
+            add_element_label=_('Add verification Field'),
+            element_kwargs={'render_br': False, 'options': verify_fields},
+            hint=_('Only text, date and email fields can be used'),
+        )
+
         widget = form.add(
             WcsExtraStringWidget,
             'drafts_lifespan',
@@ -473,6 +488,7 @@ class OptionsDirectory(Directory):
                 'only_allow_one',
                 'disabled',
                 'enable_tracking_codes',
+                'tracking_code_verify_fields',
                 'always_advertise',
                 'disabled_redirection',
                 'publication_date',
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 1b3549a50..d1f05de71 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -147,6 +147,7 @@ class FormDef(StorableObject):
     disabled = False
     only_allow_one = False
     enable_tracking_codes = False
+    tracking_code_verify_fields = None
     disabled_redirection = None
     always_advertise = False
     publication_date = None
@@ -1012,7 +1013,7 @@ class FormDef(StorableObject):
         if self.max_field_id is None and self.fields:
             self.max_field_id = max(lax_int(x.id) for x in self.fields)
 
-        more_attributes = []
+        more_attributes = ['tracking_code_verify_fields']
         if self.max_field_id:
             more_attributes.append('max_field_id')
 
@@ -1128,7 +1129,7 @@ class FormDef(StorableObject):
                     formdef.workflow_id = w.id
                     break
 
-        more_attributes = ['max_field_id']
+        more_attributes = ['max_field_id', 'tracking_code_verify_fields']
         for attribute in cls.TEXT_ATTRIBUTES + cls.BOOLEAN_ATTRIBUTES + more_attributes:
             if attribute in value:
                 setattr(formdef, attribute, value.get(attribute))
@@ -1209,6 +1210,11 @@ class FormDef(StorableObject):
         if self.max_field_id:
             ET.SubElement(root, 'max_field_id').text = str(self.max_field_id)
 
+        if self.tracking_code_verify_fields is not None:
+            verify_fields = ET.SubElement(root, 'tracking_code_verify_fields')
+            for field_id in self.tracking_code_verify_fields:
+                ET.SubElement(verify_fields, 'field_id').text = str(field_id)
+
         fields = ET.SubElement(root, 'fields')
         for field in self.fields or []:
             fields.append(field.export_to_xml(charset=charset, include_id=include_id))
@@ -1400,6 +1406,12 @@ class FormDef(StorableObject):
             else:
                 formdef.max_field_id = max(lax_int(x.id) for x in formdef.fields)
 
+        if tree.find('tracking_code_verify_fields') is not None:
+            formdef.tracking_code_verify_fields = [
+                xml_node_text(verify_field_id)
+                for verify_field_id in tree.findall('tracking_code_verify_fields/field_id')
+            ]
+
         formdef.workflow_options = {}
         for option in tree.findall('options/option'):
             option_value = None
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index b7ae58c9a..b7d5331cb 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -45,7 +45,7 @@ from wcs.roles import logged_users_role
 from wcs.variables import LazyFormDef
 from wcs.workflows import Workflow, WorkflowBackofficeFieldsFormDef, WorkflowStatusItem
 
-from ..qommon import _, emails, errors, get_cfg, misc, template
+from ..qommon import _, emails, errors, get_cfg, misc, ngettext, template
 from ..qommon.admin.emails import EmailsDirectory
 from ..qommon.form import CheckboxWidget, EmailWidget, Form, HiddenErrorWidget, HtmlWidget, StringWidget
 from ..qommon.template import TemplateError
@@ -181,6 +181,43 @@ class TrackingCodeDirectory(Directory):
             raise errors.TraversalError()
         if get_request().is_from_bot():
             raise errors.AccessForbiddenError()
+
+        verify_fields = []
+        for field in formdata.formdef.fields:
+            if field.id in (formdata.formdef.tracking_code_verify_fields or []):
+                if formdata.status == 'draft' and not formdata.data.get(field.id):
+                    # a draft could be incomplete: do not test its empty values
+                    continue
+                verify_fields.append(field)
+        if verify_fields:
+            form = Form()
+            for field in verify_fields:
+                widget = field.add_to_form(form)
+                widget.field = field
+            form.add_submit('submit', _('Verify'))
+            form.add_submit('cancel', _('Cancel'))
+
+            if form.get_submit() == 'cancel':
+                return redirect('/')
+
+            if form.is_submitted() and not form.has_errors():
+                for field in verify_fields:
+                    value = formdata.data.get(field.id)
+                    verify_value = form.get_widget('f%s' % field.id).parse()
+                    if value != verify_value:
+                        raise errors.AccessForbiddenError(_('Access rights verification failed'))
+            else:
+                html_top()
+                r = TemplateIO(html=True)
+                r += htmltext('<h2>%s</h2>') % _('Access rights verification')
+                r += htmltext('<p>%s</p>') % ngettext(
+                    'In order to be able to access the form, indicate the content of the following field.',
+                    'In order to be able to access the form, indicate the content of the following fields.',
+                    len(verify_fields),
+                )
+                r += form.render()
+                return r.getvalue()
+
         get_session().mark_anonymous_formdata(formdata)
         return redirect(formdata.get_url())
 
-- 
2.35.1

