aboutsummaryrefslogtreecommitdiffstats
path: root/api
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2018-09-23 13:30:56 +0200
committerGravatar Johannes Christ <[email protected]>2018-09-23 13:30:56 +0200
commit5fd256e5e6e4a30ca979f9bd4edc8daf55034699 (patch)
tree440440cf6de3cc957caaaf53ae94df1d0c1551dc /api
parentAdd the `Tag` model. (diff)
Add embed validation.
Diffstat (limited to 'api')
-rw-r--r--api/admin.py3
-rw-r--r--api/migrations/0008_tag_embed_validator.py20
-rw-r--r--api/models.py5
-rw-r--r--api/tests/test_validators.py143
-rw-r--r--api/validators.py155
5 files changed, 324 insertions, 2 deletions
diff --git a/api/admin.py b/api/admin.py
index 54cb33ea..526bea52 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -3,7 +3,7 @@ from django.contrib import admin
from .models import (
DocumentationLink, Member,
OffTopicChannelName, Role,
- SnakeName
+ SnakeName, Tag
)
@@ -12,3 +12,4 @@ admin.site.register(Member)
admin.site.register(OffTopicChannelName)
admin.site.register(Role)
admin.site.register(SnakeName)
+admin.site.register(Tag)
diff --git a/api/migrations/0008_tag_embed_validator.py b/api/migrations/0008_tag_embed_validator.py
new file mode 100644
index 00000000..4c580294
--- /dev/null
+++ b/api/migrations/0008_tag_embed_validator.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.1.1 on 2018-09-23 10:07
+
+import api.validators
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0007_tag'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tag',
+ name='embed',
+ field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[api.validators.validate_tag_embed]),
+ ),
+ ]
diff --git a/api/models.py b/api/models.py
index 58668c34..e84e28c0 100644
--- a/api/models.py
+++ b/api/models.py
@@ -4,6 +4,8 @@ from django.contrib.postgres import fields as pgfields
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
+from .validators import validate_tag_embed
+
class ModelReprMixin:
"""
@@ -174,5 +176,6 @@ class Tag(ModelReprMixin, models.Model):
primary_key=True
)
embed = pgfields.JSONField(
- help_text="The actual embed shown by this tag."
+ help_text="The actual embed shown by this tag.",
+ validators=(validate_tag_embed,)
)
diff --git a/api/tests/test_validators.py b/api/tests/test_validators.py
new file mode 100644
index 00000000..ce197dec
--- /dev/null
+++ b/api/tests/test_validators.py
@@ -0,0 +1,143 @@
+from django.core.exceptions import ValidationError
+from hypothesis import given, settings
+from hypothesis.extra.django import TestCase
+from hypothesis.strategies import dictionaries, just, lists, one_of, randoms, text
+
+from ..validators import validate_tag_embed
+
+
+REQUIRED_KEYS = (
+ 'content', 'fields', 'image', 'title', 'video'
+)
+
+
+class TagEmbedValidatorTests(TestCase):
+ @given(
+ dictionaries(
+ text().filter(lambda key: key not in REQUIRED_KEYS),
+ text()
+ )
+ )
+ def test_rejects_missing_required_keys(self, embed):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed(embed)
+
+ def test_rejects_empty_required_key(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': ''
+ })
+
+ @given(lists(randoms()))
+ def test_rejects_list_as_embed(self, embed):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed(embed)
+
+ def test_rejects_required_keys_and_unknown_keys(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "the duck walked up to the lemonade stand",
+ 'and': "he said to the man running the stand"
+ })
+
+ def test_rejects_too_long_title(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': 'a' * 257
+ })
+
+ @given(
+ dictionaries(
+ just('fields'),
+ one_of(
+ lists(randoms(), min_size=25),
+ text(min_size=25)
+ )
+ )
+ )
+ @settings(max_examples=10)
+ def test_rejects_too_many_fields(self, embed):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed(embed)
+
+ def test_rejects_too_long_description(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'description': 'd' * 2049
+ })
+
+ def test_rejects_fields_as_list_of_non_mappings(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': ['abc']
+ })
+
+ def test_rejects_fields_with_unknown_fields(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': [
+ {
+ 'what': "is this field"
+ }
+ ]
+ })
+
+ def test_rejects_fields_with_too_long_name(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': [
+ {
+ 'name': "a" * 257
+ }
+ ]
+ })
+
+ def test_rejects_footer_as_non_mapping(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': []
+ })
+
+ def test_rejects_footer_with_unknown_fields(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': {
+ 'duck': "quack"
+ }
+ })
+
+ def test_rejects_footer_with_empty_text(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': {
+ 'text': ""
+ }
+ })
+
+ def test_rejects_author_as_non_mapping(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': []
+ })
+
+ def test_rejects_author_with_unknown_field(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': {
+ 'field': "that is unknown"
+ }
+ })
+
+ def test_rejects_author_with_empty_name(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': {
+ 'name': ""
+ }
+ })
diff --git a/api/validators.py b/api/validators.py
new file mode 100644
index 00000000..2c4ffe4b
--- /dev/null
+++ b/api/validators.py
@@ -0,0 +1,155 @@
+from collections.abc import Mapping
+
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxLengthValidator, MinLengthValidator
+
+
+def validate_tag_embed_fields(fields):
+ field_validators = {
+ 'name': (MaxLengthValidator(limit_value=256),),
+ 'value': (MaxLengthValidator(limit_value=1024),)
+ }
+
+ for field in fields:
+ if not isinstance(field, Mapping):
+ raise ValidationError("Embed fields must be a mapping.")
+
+ for field_name, value in field.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed field field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed_footer(footer):
+ field_validators = {
+ 'text': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Footer text must not be empty."
+ ),
+ MaxLengthValidator(limit_value=2048)
+ ),
+ 'icon_url': (),
+ 'proxy_icon_url': ()
+ }
+
+ if not isinstance(footer, Mapping):
+ raise ValidationError("Embed footer must be a mapping.")
+
+ for field_name, value in footer.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed footer field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed_author(author):
+ field_validators = {
+ 'name': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Embed author name must not be empty."
+ ),
+ MaxLengthValidator(limit_value=256)
+ ),
+ 'url': (),
+ 'icon_url': (),
+ 'proxy_icon_url': ()
+ }
+
+ if not isinstance(author, Mapping):
+ raise ValidationError("Embed author must be a mapping.")
+
+ for field_name, value in author.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed author field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed(embed):
+ """
+ Validate a JSON document containing an embed as possible to send
+ on Discord. This attempts to rebuild the validation used by Discord
+ as well as possible by checking for various embed limits so we can
+ ensure that any embed we store here will also be accepted as a
+ valid embed by the Discord API.
+
+ Using this directly is possible, although not intended - you usually
+ stick this onto the `validators` keyword argument of model fields.
+
+ Example:
+
+ >>> from django.contrib.postgres import fields as pgfields
+ >>> from django.db import models
+ >>> from api.validators import validate_tag_embed
+ >>> class MyMessage(models.Model):
+ ... embed = pgfields.JSONField(
+ ... validators=(
+ ... validate_tag_embed,
+ ... )
+ ... )
+ ... # ...
+ ...
+
+ Args:
+ embed (Dict[str, Union[str, List[dict], dict]]):
+ A dictionary describing the contents of this embed.
+ See the official documentation for a full reference
+ of accepted keys by this dictionary:
+ https://discordapp.com/developers/docs/resources/channel#embed-object
+
+ Raises:
+ ValidationError:
+ In case the given embed is deemed invalid, a `ValidationError`
+ is raised which in turn will allow Django to display errors
+ as appropriate.
+ """
+
+ all_keys = {
+ 'title', 'type', 'description', 'url', 'timestamp',
+ 'color', 'footer', 'image', 'thumbnail', 'video',
+ 'provider', 'author', 'fields'
+ }
+ one_required_of = {'content', 'fields', 'image', 'title', 'video'}
+ field_validators = {
+ 'title': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Embed title must not be empty."
+ ),
+ MaxLengthValidator(limit_value=256)
+ ),
+ 'description': (MaxLengthValidator(limit_value=2048),),
+ 'fields': (
+ MaxLengthValidator(limit_value=25),
+ validate_tag_embed_fields
+ ),
+ 'footer': (validate_tag_embed_footer,),
+ 'author': (validate_tag_embed_author,)
+ }
+
+ if not embed:
+ raise ValidationError("Tag embed must not be empty.")
+
+ elif not isinstance(embed, Mapping):
+ raise ValidationError("Tag embed must be a mapping.")
+
+ elif not any(field in embed for field in one_required_of):
+ raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.")
+
+ for required_key in one_required_of:
+ if required_key in embed and not embed[required_key]:
+ raise ValidationError(f"Key {required_key!r} must not be empty.")
+
+ for field_name, value in embed.items():
+ if field_name not in all_keys:
+ raise ValidationError(f"Unknown field name: {field_name!r}")
+
+ if field_name in field_validators:
+ for validator in field_validators[field_name]:
+ validator(value)