Commit c3de810 Create a validator class for POST data

3 files Authored and Committed by abompard 2 months ago
Create a validator class for POST data

    
 1 @@ -439,3 +439,42 @@
 2           self.assertEqual(
 3               result_data["result"]["hub"]["chat_domain"],
 4               app.config["CHAT_NETWORKS"][0]["domain"])
 5 + 
 6 +     def test_hub_config_post_unknown_post_data(self):
 7 +         # Unknown POST data is silently ignored
 8 +         user = tests.FakeAuthorization('ralph')
 9 +         with tests.auth_set(app, user):
10 +             url = '/ralph/config'
11 +             result = self.app.post(url, data={"non_existant": "dummy"})
12 +         self.assertEqual(result.status_code, 400)
13 +         result_data = json.loads(result.get_data(as_text=True))
14 +         self.assertEqual(result_data["status"], "ERROR")
15 +         self.assertEqual(result_data["message"], "Invalid value(s)")
16 +         self.assertIn("non_existant", result_data["fields"])
17 +         self.assertEqual(
18 +             result_data["fields"]["non_existant"],
19 +             "Unexpected parameter."
20 +             )
21 + 
22 +     def test_hub_config_post_invalid_chat_domain(self):
23 +         user = tests.FakeAuthorization('ralph')
24 +         with tests.auth_set(app, user):
25 +             url = '/ralph/config'
26 +             result = self.app.post(url, data={"chat_domain": "dummy"})
27 +         self.assertEqual(result.status_code, 400)
28 +         result_data = json.loads(result.get_data(as_text=True))
29 +         self.assertEqual(result_data["status"], "ERROR")
30 +         self.assertEqual(result_data["message"], "Invalid value(s)")
31 +         self.assertIn("chat_domain", result_data["fields"])
32 +         self.assertEqual(
33 +             result_data["fields"]["chat_domain"],
34 +             "Unsupported chat domain."
35 +             )
36 + 
37 +     @unittest.skip("Authorization layer not present yet")
38 +     def test_hub_config_unauthorized(self):
39 +         user = tests.FakeAuthorization('ralph')
40 +         with tests.auth_set(app, user):
41 +             url = '/decause/config'
42 +             result = self.app.post(url, data={"summary": "Defaced!"})
43 +         self.assertEqual(result.status_code, 403)
 1 @@ -5,7 +5,7 @@
 2   import hubs.models
 3   
 4   from hubs.app import app, session
 5 - from .utils import get_hub, login_required
 6 + from .utils import get_hub, login_required, RequestValidator
 7   
 8   
 9   @app.route('/<name>')
10 @@ -155,14 +155,30 @@
11           },
12       }
13       if flask.request.method == 'POST':
14 -         for key in hub.config.__json__():
15 -             # Security: don't save every key in the POST.
16 -             if key not in flask.request.form:
17 -                 continue
18 -             setattr(hub.config, key, flask.request.form[key])
19 -         if (not flask.request.form["chat_domain"]
20 -             and len(app.config["CHAT_NETWORKS"]) > 0):
21 -             hub.config.chat_domain = app.config["CHAT_NETWORKS"][0]["domain"]
22 +         # Validate values
23 +         def _validate_chat_domain(value):
24 +             valid_chat_domains = [
25 +                 network["domain"] for network in app.config["CHAT_NETWORKS"]
26 +                 ]
27 +             if not value and len(valid_chat_domains) > 0:
28 +                 value = valid_chat_domains[0]
29 +             if value not in valid_chat_domains:
30 +                 raise ValueError("Unsupported chat domain.")
31 +             return value
32 +         validator = RequestValidator(dict(
33 +             # Only allow the parameters listed in __json__().
34 +             (key, None) for key in hub.config.__json__().keys()
35 +         ))
36 +         validator.converters["chat_domain"] = _validate_chat_domain
37 +         try:
38 +             values = validator(flask.request.form)
39 +         except ValueError as e:
40 +             config = {"status": "ERROR", "message": "Invalid value(s)"}
41 +             config["fields"] = e.args[0]
42 +             return flask.jsonify(config), 400
43 +         # Now set the configuration values
44 +         for key, value in values.items():
45 +             setattr(hub.config, key, value)
46           try:
47               flask.g.db.commit()
48           except Exception as err:
 1 @@ -145,3 +145,81 @@
 2       if position not in ['right', 'left']:
 3           flask.abort(400, 'Invalid position provided')
 4       return position
 5 + 
 6 + 
 7 + class RequestValidator(object):
 8 +     """Validate a request's POST data.
 9 + 
10 +     Instantiate this class with a dictionnary mapping POST data keys (found in
11 +     ``flask.request.form``) to a function that will validate and convert them.
12 + 
13 +     POST data can be validated by calling the instance and passing the data as
14 +     an argument.  The instance will return a mapping of keys to converted
15 +     values if all validations are successful.
16 + 
17 +     A validation function can raise a :py:exc:`ValueError` if the value is not
18 +     valid. In this case, the :py:class:`RequestValidator` instance will collect
19 +     the error messages in a dictionary mapping failed request keys to messages,
20 +     and raise a :py:exc:`ValueError` with this dictionary.
21 + 
22 +     If the function is None, the value is returned unchanged. If a posted value
23 +     is not present in the conversion mapping the class was instantiated with,
24 +     an error will be raised.
25 + 
26 +     A typical usage of this class is::
27 + 
28 +         validator = RequestValidator({"number": int})
29 +         try:
30 +             values = validator(flask.request.form)
31 +         except ValueError as e:
32 +             failed_fields = e.args[0]
33 +             return flask.jsonify(failed_fields), 400
34 +         # Do something with the validated values.
35 +         for key, value in values.items():
36 +             setattr(model_instance, key, value)
37 + 
38 +     In this example:
39 + 
40 +     - if the posted data was ``number=1``, the resulting ``values`` variable
41 +       would be ``{"number": 1}``.
42 +     - if the posted data was ``number=foobar``, a :py:exc:`ValueError` would be
43 +       raised, ``failed_fields`` would be ``{"number": "error message"}``.
44 +     - if the posted data was ``foo=bar``, a :py:exc:`ValueError` would be
45 +       raised, ``failed_fields`` would be
46 +       ``{"foo": "message about unexpected key"}``.
47 + 
48 +     Args:
49 +         converters (dict): a mapping from POST data keys to
50 +             conversion functions.
51 +     """
52 + 
53 +     def __init__(self, converters):
54 +         self.converters = converters.copy()
55 + 
56 +     def __call__(self, request_data):
57 +         """Convert the values in the given mapping.
58 + 
59 +         Args:
60 +             request_data (dict): the values to validate and convert,
61 +                 usually ``flask.request.form``.
62 + 
63 +         Raises:
64 +             ValueError: At least one value is invalid.
65 +         """
66 +         values = {}
67 +         errors = {}
68 +         for key, value in request_data.items():
69 +             if key not in self.converters:
70 +                 errors[key] = "Unexpected parameter."
71 +                 continue
72 +             if self.converters[key] is None:
73 +                 # Identity
74 +                 values[key] = value
75 +                 continue
76 +             try:
77 +                 values[key] = self.converters[key](value)
78 +             except (TypeError, ValueError) as e:
79 +                 errors[key] = e.args[0]
80 +         if errors:
81 +             raise ValueError(errors)
82 +         return values