Repository: bookwyrm-social/bookwyrm

Issue:
username validation isn't catching everything it should
Users can create usernames with spaces in them


Discussion:

Resolving patch:
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -5,7 +5,6 @@
 
 import dateutil.parser
 from dateutil.parser import ParserError
-from django.contrib.auth.models import AbstractUser
 from django.contrib.postgres.fields import ArrayField as DjangoArrayField
 from django.core.exceptions import ValidationError
 from django.core.files.base import ContentFile
@@ -25,6 +24,14 @@ def validate_remote_id(value):
             params={'value': value},
         )
 
+def validate_username(value):
+    ''' make sure usernames look okay '''
+    if not re.match(r'^[A-Za-z\-_\.]+$', value):
+        raise ValidationError(
+            _('%(value)s is not a valid remote_id'),
+            params={'value': value},
+        )
+
 
 class ActivitypubFieldMixin:
     ''' make a database field serializable '''
@@ -134,7 +141,7 @@ def __init__(self, activitypub_field='preferredUsername'):
             _('username'),
             max_length=150,
             unique=True,
-            validators=[AbstractUser.username_validator],
+            validators=[validate_username],
             error_messages={
                 'unique': _('A user with that username already exists.'),
             },
diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py
--- a/bookwyrm/view_actions.py
+++ b/bookwyrm/view_actions.py
@@ -66,7 +66,7 @@ def register(request):
     if not form.is_valid():
         errors = True
 
-    username = form.data['username']
+    username = form.data['username'].strip()
     email = form.data['email']
     password = form.data['password']
 
@@ -215,6 +215,7 @@ def edit_profile(request):
     return redirect('/user/%s' % request.user.localname)
 
 
+@require_POST
 def resolve_book(request):
     ''' figure out the local path to a book from a remote_id '''
     remote_id = request.POST.get('remote_id')


Test patch:
diff --git a/bookwyrm/tests/test_view_actions.py b/bookwyrm/tests/test_view_actions.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/tests/test_view_actions.py
@@ -0,0 +1,239 @@
+''' test for app action functionality '''
+from unittest.mock import patch
+
+from django.core.exceptions import PermissionDenied
+from django.http.response import Http404
+from django.test import TestCase
+from django.test.client import RequestFactory
+
+from bookwyrm import view_actions as actions, models
+from bookwyrm.settings import DOMAIN
+
+
+#pylint: disable=too-many-public-methods
+class ViewActions(TestCase):
+    ''' a lot here: all handlers for receiving activitypub requests '''
+    def setUp(self):
+        ''' we need basic things, like users '''
+        self.local_user = models.User.objects.create_user(
+            'mouse', 'mouse@mouse.com', 'mouseword', local=True)
+        self.local_user.remote_id = 'https://example.com/user/mouse'
+        self.local_user.save()
+        with patch('bookwyrm.models.user.set_remote_server.delay'):
+            self.remote_user = models.User.objects.create_user(
+                'rat', 'rat@rat.com', 'ratword',
+                local=False,
+                remote_id='https://example.com/users/rat',
+                inbox='https://example.com/users/rat/inbox',
+                outbox='https://example.com/users/rat/outbox',
+            )
+        self.status = models.Status.objects.create(
+            user=self.local_user,
+            content='Test status',
+            remote_id='https://example.com/status/1',
+        )
+        self.settings = models.SiteSettings.objects.create(id=1)
+        self.factory = RequestFactory()
+
+
+    def test_register(self):
+        ''' create a user '''
+        self.assertEqual(models.User.objects.count(), 2)
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria-user.user_nutria',
+                'password': 'mouseword',
+                'email': 'aa@bb.cccc'
+            })
+        with patch('bookwyrm.view_actions.login'):
+            response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 3)
+        self.assertEqual(response.status_code, 302)
+        nutria = models.User.objects.last()
+        self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN)
+        self.assertEqual(nutria.localname, 'nutria-user.user_nutria')
+        self.assertEqual(nutria.local, True)
+
+    def test_register_trailing_space(self):
+        ''' django handles this so weirdly '''
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria ',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc'
+            })
+        with patch('bookwyrm.view_actions.login'):
+            response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 3)
+        self.assertEqual(response.status_code, 302)
+        nutria = models.User.objects.last()
+        self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN)
+        self.assertEqual(nutria.localname, 'nutria')
+        self.assertEqual(nutria.local, True)
+
+    def test_register_invalid_email(self):
+        ''' gotta have an email '''
+        self.assertEqual(models.User.objects.count(), 2)
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria',
+                'password': 'mouseword',
+                'email': 'aa'
+            })
+        response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 2)
+        self.assertEqual(response.template_name, 'login.html')
+
+    def test_register_invalid_username(self):
+        ''' gotta have an email '''
+        self.assertEqual(models.User.objects.count(), 2)
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nut@ria',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc'
+            })
+        response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 2)
+        self.assertEqual(response.template_name, 'login.html')
+
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutr ia',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc'
+            })
+        response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 2)
+        self.assertEqual(response.template_name, 'login.html')
+
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nut@ria',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc'
+            })
+        response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 2)
+        self.assertEqual(response.template_name, 'login.html')
+
+
+    def test_register_closed_instance(self):
+        ''' you can't just register '''
+        self.settings.allow_registration = False
+        self.settings.save()
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria ',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc'
+            })
+        with self.assertRaises(PermissionDenied):
+            actions.register(request)
+
+    def test_register_invite(self):
+        ''' you can't just register '''
+        self.settings.allow_registration = False
+        self.settings.save()
+        models.SiteInvite.objects.create(
+            code='testcode', user=self.local_user, use_limit=1)
+        self.assertEqual(models.SiteInvite.objects.get().times_used, 0)
+
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc',
+                'invite_code': 'testcode'
+            })
+        with patch('bookwyrm.view_actions.login'):
+            response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 3)
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(models.SiteInvite.objects.get().times_used, 1)
+
+        # invalid invite
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria2',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc',
+                'invite_code': 'testcode'
+            })
+        response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 3)
+
+        # bad invite code
+        request = self.factory.post(
+            'register/',
+            {
+                'username': 'nutria3',
+                'password': 'mouseword',
+                'email': 'aa@bb.ccc',
+                'invite_code': 'dkfkdjgdfkjgkdfj'
+            })
+        with self.assertRaises(Http404):
+            response = actions.register(request)
+        self.assertEqual(models.User.objects.count(), 3)
+
+
+    def test_password_reset_request(self):
+        ''' send 'em an email '''
+        request = self.factory.post('', {'email': 'aa@bb.ccc'})
+        resp = actions.password_reset_request(request)
+        self.assertEqual(resp.status_code, 302)
+
+        request = self.factory.post(
+            '', {'email': 'mouse@mouse.com'})
+        with patch('bookwyrm.emailing.send_email.delay'):
+            resp = actions.password_reset_request(request)
+        self.assertEqual(resp.template_name, 'password_reset_request.html')
+
+        self.assertEqual(
+            models.PasswordReset.objects.get().user, self.local_user)
+
+    def test_password_reset(self):
+        ''' reset from code '''
+        code = models.PasswordReset.objects.create(user=self.local_user)
+        request = self.factory.post('', {
+            'reset-code':  code.code,
+            'password': 'hi',
+            'confirm-password': 'hi'
+        })
+        with patch('bookwyrm.view_actions.login'):
+            resp = actions.password_reset(request)
+        self.assertEqual(resp.status_code, 302)
+        self.assertFalse(models.PasswordReset.objects.exists())
+
+    def test_password_reset_wrong_code(self):
+        ''' reset from code '''
+        models.PasswordReset.objects.create(user=self.local_user)
+        request = self.factory.post('', {
+            'reset-code': 'jhgdkfjgdf',
+            'password': 'hi',
+            'confirm-password': 'hi'
+        })
+        resp = actions.password_reset(request)
+        self.assertEqual(resp.template_name, 'password_reset.html')
+        self.assertTrue(models.PasswordReset.objects.exists())
+
+    def test_password_reset_mismatch(self):
+        ''' reset from code '''
+        code = models.PasswordReset.objects.create(user=self.local_user)
+        request = self.factory.post('', {
+            'reset-code': code.code,
+            'password': 'hi',
+            'confirm-password': 'hihi'
+        })
+        resp = actions.password_reset(request)
+        self.assertEqual(resp.template_name, 'password_reset.html')
+        self.assertTrue(models.PasswordReset.objects.exists())