From 61041a0dba1b2b72c231efc393f836e5e154ab42 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 13 Dec 2020 21:11:53 -0800 Subject: [PATCH 01/14] Adds migration for privacy field no actual change here, but it's a new field type --- .../migrations/0023_auto_20201214_0511.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bookwyrm/migrations/0023_auto_20201214_0511.py diff --git a/bookwyrm/migrations/0023_auto_20201214_0511.py b/bookwyrm/migrations/0023_auto_20201214_0511.py new file mode 100644 index 000000000..e811bded8 --- /dev/null +++ b/bookwyrm/migrations/0023_auto_20201214_0511.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.7 on 2020-12-14 05:11 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0022_auto_20201212_1744'), + ] + + operations = [ + migrations.AlterField( + model_name='status', + name='privacy', + field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), + ), + ] From badc5d0dab5be73ae43982144af71b5edd3bd0f9 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 10:18:14 -0800 Subject: [PATCH 02/14] Adds another shelf model test --- bookwyrm/tests/models/test_book_model.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/bookwyrm/tests/models/test_book_model.py b/bookwyrm/tests/models/test_book_model.py index 125649928..ff56b5d8c 100644 --- a/bookwyrm/tests/models/test_book_model.py +++ b/bookwyrm/tests/models/test_book_model.py @@ -8,6 +8,7 @@ from bookwyrm.models.book import isbn_10_to_13, isbn_13_to_10 class Book(TestCase): ''' not too much going on in the books model but here we are ''' def setUp(self): + ''' we'll need some books ''' self.work = models.Work.objects.create( title='Example Work', remote_id='https://example.com/book/1' @@ -22,6 +23,7 @@ class Book(TestCase): ) def test_remote_id(self): + ''' fanciness with remote/origin ids ''' remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id) self.assertEqual(self.work.get_remote_id(), remote_id) self.assertEqual(self.work.remote_id, remote_id) @@ -54,17 +56,3 @@ class Book(TestCase): isbn_13 = '978-1788-16167-1' isbn_10 = isbn_13_to_10(isbn_13) self.assertEqual(isbn_10, '178816167X') - - -class Shelf(TestCase): - def setUp(self): - user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) - models.Shelf.objects.create( - name='Test Shelf', identifier='test-shelf', user=user) - - def test_remote_id(self): - ''' editions and works use the same absolute id syntax ''' - shelf = models.Shelf.objects.get(identifier='test-shelf') - expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN - self.assertEqual(shelf.get_remote_id(), expected_id) From 7f6390f722bf814d9427f71539f58a8976956259 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 10:25:43 -0800 Subject: [PATCH 03/14] Test incoming in one file --- bookwyrm/tests/incoming/__init__.py | 1 - bookwyrm/tests/incoming/test_favorite.py | 51 ------- bookwyrm/tests/incoming/test_follow.py | 77 ---------- bookwyrm/tests/incoming/test_follow_accept.py | 52 ------- bookwyrm/tests/models/test_shelf_model.py | 30 ++++ bookwyrm/tests/test_incoming.py | 136 ++++++++++++++++++ 6 files changed, 166 insertions(+), 181 deletions(-) delete mode 100644 bookwyrm/tests/incoming/__init__.py delete mode 100644 bookwyrm/tests/incoming/test_favorite.py delete mode 100644 bookwyrm/tests/incoming/test_follow.py delete mode 100644 bookwyrm/tests/incoming/test_follow_accept.py create mode 100644 bookwyrm/tests/models/test_shelf_model.py create mode 100644 bookwyrm/tests/test_incoming.py diff --git a/bookwyrm/tests/incoming/__init__.py b/bookwyrm/tests/incoming/__init__.py deleted file mode 100644 index b6e690fd5..000000000 --- a/bookwyrm/tests/incoming/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import * diff --git a/bookwyrm/tests/incoming/test_favorite.py b/bookwyrm/tests/incoming/test_favorite.py deleted file mode 100644 index 912657da5..000000000 --- a/bookwyrm/tests/incoming/test_favorite.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import pathlib -from unittest.mock import patch -from django.test import TestCase - -from bookwyrm import models, incoming - - -class Favorite(TestCase): - def setUp(self): - with patch('bookwyrm.models.user.set_remote_server.delay'): - with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword', local=True, - remote_id='http://local.com/user/mouse') - - self.status = models.Status.objects.create( - user=self.local_user, - content='Test status', - remote_id='http://local.com/status/1', - ) - - datafile = pathlib.Path(__file__).parent.joinpath( - '../data/ap_user.json' - ) - self.user_data = json.loads(datafile.read_bytes()) - - - - def test_handle_favorite(self): - activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'http://example.com/fav/1', - 'actor': 'https://example.com/users/rat', - 'published': 'Mon, 25 May 2020 19:31:20 GMT', - 'object': 'http://local.com/status/1', - } - - incoming.handle_favorite(activity) - - fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1') - self.assertEqual(fav.status, self.status) - self.assertEqual(fav.remote_id, 'http://example.com/fav/1') - self.assertEqual(fav.user, self.remote_user) diff --git a/bookwyrm/tests/incoming/test_follow.py b/bookwyrm/tests/incoming/test_follow.py deleted file mode 100644 index 799907dac..000000000 --- a/bookwyrm/tests/incoming/test_follow.py +++ /dev/null @@ -1,77 +0,0 @@ -from unittest.mock import patch -from django.test import TestCase - -from bookwyrm import models, incoming - - -class IncomingFollow(TestCase): - def setUp(self): - with patch('bookwyrm.models.user.set_remote_server.delay'): - with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword', local=True) - self.local_user.remote_id = 'http://local.com/user/mouse' - self.local_user.save() - - - def test_handle_follow(self): - activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/users/rat/follows/123", - "type": "Follow", - "actor": "https://example.com/users/rat", - "object": "http://local.com/user/mouse" - } - - with patch('bookwyrm.broadcast.broadcast_task.delay') as _: - incoming.handle_follow(activity) - - # notification created - notification = models.Notification.objects.get() - self.assertEqual(notification.user, self.local_user) - self.assertEqual(notification.notification_type, 'FOLLOW') - - # the request should have been deleted - requests = models.UserFollowRequest.objects.all() - self.assertEqual(list(requests), []) - - # the follow relationship should exist - follow = models.UserFollows.objects.get(user_object=self.local_user) - self.assertEqual(follow.user_subject, self.remote_user) - - - def test_handle_follow_manually_approved(self): - activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/users/rat/follows/123", - "type": "Follow", - "actor": "https://example.com/users/rat", - "object": "http://local.com/user/mouse" - } - - self.local_user.manually_approves_followers = True - self.local_user.save() - - with patch('bookwyrm.broadcast.broadcast_task.delay') as _: - incoming.handle_follow(activity) - - # notification created - notification = models.Notification.objects.get() - self.assertEqual(notification.user, self.local_user) - self.assertEqual(notification.notification_type, 'FOLLOW_REQUEST') - - # the request should exist - request = models.UserFollowRequest.objects.get() - self.assertEqual(request.user_subject, self.remote_user) - self.assertEqual(request.user_object, self.local_user) - - # the follow relationship should not exist - follow = models.UserFollows.objects.all() - self.assertEqual(list(follow), []) diff --git a/bookwyrm/tests/incoming/test_follow_accept.py b/bookwyrm/tests/incoming/test_follow_accept.py deleted file mode 100644 index d6e048fb8..000000000 --- a/bookwyrm/tests/incoming/test_follow_accept.py +++ /dev/null @@ -1,52 +0,0 @@ -from unittest.mock import patch -from django.test import TestCase - -from bookwyrm import models, incoming - - -class IncomingFollowAccept(TestCase): - def setUp(self): - with patch('bookwyrm.models.user.set_remote_server.delay'): - with patch('bookwyrm.models.user.get_remote_reviews.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.local_user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword', local=True) - self.local_user.remote_id = 'http://local.com/user/mouse' - self.local_user.save() - - - def test_handle_follow_accept(self): - activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/users/rat/follows/123#accepts", - "type": "Accept", - "actor": "https://example.com/users/rat", - "object": { - "id": "https://example.com/users/rat/follows/123", - "type": "Follow", - "actor": "http://local.com/user/mouse", - "object": "https://example.com/users/rat" - } - } - - models.UserFollowRequest.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) - self.assertEqual(models.UserFollowRequest.objects.count(), 1) - - incoming.handle_follow_accept(activity) - - # request should be deleted - self.assertEqual(models.UserFollowRequest.objects.count(), 0) - - # relationship should be created - follows = self.remote_user.followers - self.assertEqual(follows.count(), 1) - self.assertEqual(follows.first(), self.local_user) diff --git a/bookwyrm/tests/models/test_shelf_model.py b/bookwyrm/tests/models/test_shelf_model.py new file mode 100644 index 000000000..9cdc7311b --- /dev/null +++ b/bookwyrm/tests/models/test_shelf_model.py @@ -0,0 +1,30 @@ +''' testing models ''' +from django.test import TestCase + +from bookwyrm import models, settings + + +class Shelf(TestCase): + ''' some activitypub oddness ahead ''' + def setUp(self): + ''' look, a shelf ''' + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + self.shelf = models.Shelf.objects.create( + name='Test Shelf', identifier='test-shelf', user=self.user) + + def test_remote_id(self): + ''' shelves use custom remote ids ''' + expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN + self.assertEqual(self.shelf.get_remote_id(), expected_id) + + + def test_to_activity(self): + ''' jsonify it ''' + activity_json = self.shelf.to_activity() + self.assertIsInstance(activity_json, dict) + self.assertEqual(activity_json['id'], self.shelf.remote_id) + self.assertEqual(activity_json['totalItems'], 0) + self.assertEqual(activity_json['type'], 'OrderedCollection') + self.assertEqual(activity_json['name'], 'Test Shelf') + self.assertEqual(activity_json['owner'], self.user.remote_id) diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py new file mode 100644 index 000000000..674f69b91 --- /dev/null +++ b/bookwyrm/tests/test_incoming.py @@ -0,0 +1,136 @@ +''' test incoming activities ''' +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models, incoming + + +class Incoming(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 = 'http://local.com/user/mouse' + self.local_user.save() + with patch('bookwyrm.models.user.get_remote_reviews.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='http://local.com/status/1', + ) + + + def test_handle_follow(self): + ''' remote user wants to follow local user ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "http://local.com/user/mouse" + } + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + incoming.handle_follow(activity) + + # notification created + notification = models.Notification.objects.get() + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.notification_type, 'FOLLOW') + + # the request should have been deleted + requests = models.UserFollowRequest.objects.all() + self.assertEqual(list(requests), []) + + # the follow relationship should exist + follow = models.UserFollows.objects.get(user_object=self.local_user) + self.assertEqual(follow.user_subject, self.remote_user) + + + def test_handle_follow_manually_approved(self): + ''' needs approval before following ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "http://local.com/user/mouse" + } + + self.local_user.manually_approves_followers = True + self.local_user.save() + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + incoming.handle_follow(activity) + + # notification created + notification = models.Notification.objects.get() + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.notification_type, 'FOLLOW_REQUEST') + + # the request should exist + request = models.UserFollowRequest.objects.get() + self.assertEqual(request.user_subject, self.remote_user) + self.assertEqual(request.user_object, self.local_user) + + # the follow relationship should not exist + follow = models.UserFollows.objects.all() + self.assertEqual(list(follow), []) + + + def test_handle_follow_accept(self): + ''' a remote user approved a follow request from local ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123#accepts", + "type": "Accept", + "actor": "https://example.com/users/rat", + "object": { + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "http://local.com/user/mouse", + "object": "https://example.com/users/rat" + } + } + + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + self.assertEqual(models.UserFollowRequest.objects.count(), 1) + + incoming.handle_follow_accept(activity) + + # request should be deleted + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + # relationship should be created + follows = self.remote_user.followers + self.assertEqual(follows.count(), 1) + self.assertEqual(follows.first(), self.local_user) + + + def test_handle_favorite(self): + ''' fav a status ''' + activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': 'http://example.com/fav/1', + 'actor': 'https://example.com/users/rat', + 'published': 'Mon, 25 May 2020 19:31:20 GMT', + 'object': 'http://local.com/status/1', + } + + incoming.handle_favorite(activity) + + fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1') + self.assertEqual(fav.status, self.status) + self.assertEqual(fav.remote_id, 'http://example.com/fav/1') + self.assertEqual(fav.user, self.remote_user) From 171b8c75aef447ffb4ca13a906fb7c78a11f7db8 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 11:29:22 -0800 Subject: [PATCH 04/14] use require_POST decorator in inbox --- bookwyrm/incoming.py | 6 +-- bookwyrm/tests/test_incoming.py | 75 ++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index fe521772a..734549f45 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -6,6 +6,7 @@ import django.db.utils from django.http import HttpResponse from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST import requests from bookwyrm import activitypub, models, outgoing @@ -15,6 +16,7 @@ from bookwyrm.signatures import Signature @csrf_exempt +@require_POST def inbox(request, username): ''' incoming activitypub events ''' try: @@ -26,11 +28,9 @@ def inbox(request, username): @csrf_exempt +@require_POST def shared_inbox(request): ''' incoming activitypub events ''' - if request.method == 'GET': - return HttpResponseNotFound() - try: resp = request.body activity = json.loads(resp) diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 674f69b91..8b226792b 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -1,6 +1,10 @@ ''' test incoming activities ''' from unittest.mock import patch + +from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \ + HttpResponseNotFound from django.test import TestCase +from django.test.client import RequestFactory from bookwyrm import models, incoming @@ -13,7 +17,7 @@ class Incoming(TestCase): 'mouse', 'mouse@mouse.com', 'mouseword', local=True) self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.save() - with patch('bookwyrm.models.user.get_remote_reviews.delay'): + with patch('bookwyrm.models.user.set_remote_server.delay'): self.remote_user = models.User.objects.create_user( 'rat', 'rat@rat.com', 'ratword', local=False, @@ -26,6 +30,75 @@ class Incoming(TestCase): content='Test status', remote_id='http://local.com/status/1', ) + self.factory = RequestFactory() + + + def test_inbox_invalid_get(self): + ''' shouldn't try to handle if the user is not found ''' + request = self.factory.get('http://www.example.com/') + self.assertIsInstance( + incoming.inbox(request, 'anything'), HttpResponseNotAllowed) + self.assertIsInstance( + incoming.shared_inbox(request), HttpResponseNotAllowed) + + def test_inbox_invalid_user(self): + ''' shouldn't try to handle if the user is not found ''' + request = self.factory.post('http://www.example.com/') + self.assertIsInstance( + incoming.inbox(request, 'fish@tomato.com'), HttpResponseNotFound) + + def test_inbox_invalid_no_object(self): + ''' json is missing "object" field ''' + request = self.factory.post( + self.local_user.shared_inbox, data={}) + self.assertIsInstance( + incoming.shared_inbox(request), HttpResponseBadRequest) + + def test_inbox_invalid_bad_signature(self): + ''' bad request for invalid signature ''' + request = self.factory.post( + self.local_user.shared_inbox, + '{"type": "Test", "object": "exists"}', + content_type='application/json') + with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: + mock_has_valid.return_value = False + self.assertEqual( + incoming.shared_inbox(request).status_code, 401) + + def test_inbox_invalid_bad_signature_delete(self): + ''' invalid signature for Delete is okay though ''' + request = self.factory.post( + self.local_user.shared_inbox, + '{"type": "Delete", "object": "exists"}', + content_type='application/json') + with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: + mock_has_valid.return_value = False + self.assertEqual( + incoming.shared_inbox(request).status_code, 200) + + def test_inbox_unknown_type(self): + ''' never heard of that activity type, don't have a handler for it ''' + request = self.factory.post( + self.local_user.shared_inbox, + '{"type": "Fish", "object": "exists"}', + content_type='application/json') + with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: + mock_has_valid.return_value = True + self.assertIsInstance( + incoming.shared_inbox(request), HttpResponseNotFound) + + def test_inbox_success(self): + ''' a known type, for which we start a task ''' + request = self.factory.post( + self.local_user.shared_inbox, + '{"type": "Accept", "object": "exists"}', + content_type='application/json') + with patch('bookwyrm.incoming.has_valid_signature') as mock_has_valid: + mock_has_valid.return_value = True + + with patch('bookwyrm.incoming.handle_follow_accept.delay'): + self.assertEqual( + incoming.shared_inbox(request).status_code, 200) def test_handle_follow(self): From 8d27f02a9a39a03cb7bf9484ccc350981ce8fe07 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 11:46:31 -0800 Subject: [PATCH 05/14] Fixes typo in handle_unfollow --- bookwyrm/incoming.py | 2 +- bookwyrm/tests/test_incoming.py | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 734549f45..3fa9c23d7 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -141,7 +141,7 @@ def handle_follow(activity): def handle_unfollow(activity): ''' unfollow a local user ''' obj = activity['object'] - requester = activitypub.resolve_remote_id(models.user, obj['actor']) + requester = activitypub.resolve_remote_id(models.User, obj['actor']) to_unfollow = models.User.objects.get(remote_id=obj['object']) # raises models.User.DoesNotExist diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 8b226792b..4639e9fbc 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -159,6 +159,26 @@ class Incoming(TestCase): self.assertEqual(list(follow), []) + def test_handle_unfollow(self): + ''' remove a relationship ''' + activity = { + "type": "Undo", + "@context": "https://www.w3.org/ns/activitystreams", + "object": { + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "http://local.com/user/mouse" + } + } + models.UserFollows.objects.create( + user_subject=self.remote_user, user_object=self.local_user) + self.assertEqual(self.remote_user, self.local_user.followers.first()) + + incoming.handle_unfollow(activity) + self.assertIsNone(self.local_user.followers.first()) + + def test_handle_follow_accept(self): ''' a remote user approved a follow request from local ''' activity = { @@ -191,6 +211,37 @@ class Incoming(TestCase): self.assertEqual(follows.first(), self.local_user) + def test_handle_follow_reject(self): + ''' turn down a follow request ''' + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123#accepts", + "type": "Reject", + "actor": "https://example.com/users/rat", + "object": { + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "http://local.com/user/mouse", + "object": "https://example.com/users/rat" + } + } + + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + self.assertEqual(models.UserFollowRequest.objects.count(), 1) + + incoming.handle_follow_reject(activity) + + # request should be deleted + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + # relationship should be created + follows = self.remote_user.followers + self.assertEqual(follows.count(), 0) + + def test_handle_favorite(self): ''' fav a status ''' activity = { From d49e49f021d8ed413cd6ef5eda00924eedbf721e Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 12:31:11 -0800 Subject: [PATCH 06/14] Notify mentioned users --- bookwyrm/incoming.py | 10 ++++ bookwyrm/tests/data/ap_note.json | 51 +++++++++++++++++ bookwyrm/tests/test_incoming.py | 94 +++++++++++++++++++++++++++----- 3 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 bookwyrm/tests/data/ap_note.json diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 3fa9c23d7..f4f8afbbe 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -226,6 +226,16 @@ def handle_create(activity): related_user=status.user, related_status=status, ) + if status.mention_users.exists(): + for mentioned_user in status.mention_users.all(): + if not mentioned_user.local: + continue + status_builder.create_notification( + mentioned_user, + 'MENTION', + related_user=status.user, + related_status=status, + ) @app.task diff --git a/bookwyrm/tests/data/ap_note.json b/bookwyrm/tests/data/ap_note.json new file mode 100644 index 000000000..2855b75ba --- /dev/null +++ b/bookwyrm/tests/data/ap_note.json @@ -0,0 +1,51 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "https://example.com/users/rat/statuses/1234567", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2020-12-13T05:09:29Z", + "url": "https://example.com/@rat/1234567", + "attributedTo": "https://example.com/users/rat", + "to": [ + "https://example.com/user/mouse" + ], + "cc": [], + "sensitive": false, + "atomUri": "https://example.com/users/rat/statuses/1234567", + "inReplyToAtomUri": null, + "conversation": "tag:example.com,2020-12-13:objectId=7309346:objectType=Conversation", + "content": "test content in note", + "contentMap": { + "en": "

@mouse hi

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.com/user/mouse", + "name": "@mouse@example.com" + } + ], + "replies": { + "id": "https://example.com/users/rat/statuses/105371151200548049/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.com/users/rat/statuses/105371151200548049/replies?only_other_accounts=true&page=true", + "partOf": "https://example.com/users/rat/statuses/105371151200548049/replies", + "items": [] + } + } +} diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 4639e9fbc..0c8b6967c 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -1,4 +1,6 @@ ''' test incoming activities ''' +import json +import pathlib from unittest.mock import patch from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \ @@ -15,7 +17,7 @@ class Incoming(TestCase): ''' 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 = 'http://local.com/user/mouse' + 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( @@ -28,14 +30,14 @@ class Incoming(TestCase): self.status = models.Status.objects.create( user=self.local_user, content='Test status', - remote_id='http://local.com/status/1', + remote_id='https://example.com/status/1', ) self.factory = RequestFactory() def test_inbox_invalid_get(self): ''' shouldn't try to handle if the user is not found ''' - request = self.factory.get('http://www.example.com/') + request = self.factory.get('https://www.example.com/') self.assertIsInstance( incoming.inbox(request, 'anything'), HttpResponseNotAllowed) self.assertIsInstance( @@ -43,7 +45,7 @@ class Incoming(TestCase): def test_inbox_invalid_user(self): ''' shouldn't try to handle if the user is not found ''' - request = self.factory.post('http://www.example.com/') + request = self.factory.post('https://www.example.com/') self.assertIsInstance( incoming.inbox(request, 'fish@tomato.com'), HttpResponseNotFound) @@ -108,7 +110,7 @@ class Incoming(TestCase): "id": "https://example.com/users/rat/follows/123", "type": "Follow", "actor": "https://example.com/users/rat", - "object": "http://local.com/user/mouse" + "object": "https://example.com/user/mouse" } with patch('bookwyrm.broadcast.broadcast_task.delay'): @@ -135,7 +137,7 @@ class Incoming(TestCase): "id": "https://example.com/users/rat/follows/123", "type": "Follow", "actor": "https://example.com/users/rat", - "object": "http://local.com/user/mouse" + "object": "https://example.com/user/mouse" } self.local_user.manually_approves_followers = True @@ -168,7 +170,7 @@ class Incoming(TestCase): "id": "https://example.com/users/rat/follows/123", "type": "Follow", "actor": "https://example.com/users/rat", - "object": "http://local.com/user/mouse" + "object": "https://example.com/user/mouse" } } models.UserFollows.objects.create( @@ -189,7 +191,7 @@ class Incoming(TestCase): "object": { "id": "https://example.com/users/rat/follows/123", "type": "Follow", - "actor": "http://local.com/user/mouse", + "actor": "https://example.com/user/mouse", "object": "https://example.com/users/rat" } } @@ -221,7 +223,7 @@ class Incoming(TestCase): "object": { "id": "https://example.com/users/rat/follows/123", "type": "Follow", - "actor": "http://local.com/user/mouse", + "actor": "https://example.com/user/mouse", "object": "https://example.com/users/rat" } } @@ -242,19 +244,85 @@ class Incoming(TestCase): self.assertEqual(follows.count(), 0) + def test_handle_create(self): + ''' the "it justs works" mode ''' + self.assertEqual(models.Status.objects.count(), 1) + + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/ap_quotation.json') + status_data = json.loads(datafile.read_bytes()) + models.Edition.objects.create( + title='Test Book', remote_id='https://example.com/book/1') + activity = {'object': status_data, 'type': 'Create'} + incoming.handle_create(activity) + status = models.Quotation.objects.get() + self.assertEqual( + status.remote_id, 'https://example.com/user/mouse/quotation/13') + self.assertEqual(status.quote, 'quote body') + self.assertEqual(status.content, 'commentary') + self.assertEqual(status.user, self.local_user) + self.assertEqual(models.Status.objects.count(), 2) + + # while we're here, lets ensure we avoid dupes + incoming.handle_create(activity) + self.assertEqual(models.Status.objects.count(), 2) + + def test_handle_create_remote_note_with_mention(self): + ''' should only create it under the right circumstances ''' + self.assertEqual(models.Status.objects.count(), 1) + self.assertFalse( + models.Notification.objects.filter(user=self.local_user).exists()) + + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/ap_note.json') + status_data = json.loads(datafile.read_bytes()) + activity = {'object': status_data, 'type': 'Create'} + + incoming.handle_create(activity) + status = models.Status.objects.last() + self.assertEqual(status.content, 'test content in note') + self.assertEqual(status.mention_users.first(), self.local_user) + self.assertTrue( + models.Notification.objects.filter(user=self.local_user).exists()) + self.assertEqual( + models.Notification.objects.get().notification_type, 'MENTION') + + def test_handle_create_remote_note_with_reply(self): + ''' should only create it under the right circumstances ''' + self.assertEqual(models.Status.objects.count(), 1) + self.assertFalse( + models.Notification.objects.filter(user=self.local_user)) + + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/ap_note.json') + status_data = json.loads(datafile.read_bytes()) + del status_data['tag'] + status_data['inReplyTo'] = self.status.remote_id + activity = {'object': status_data, 'type': 'Create'} + + incoming.handle_create(activity) + status = models.Status.objects.last() + self.assertEqual(status.content, 'test content in note') + self.assertEqual(status.reply_parent, self.status) + self.assertTrue( + models.Notification.objects.filter(user=self.local_user)) + self.assertEqual( + models.Notification.objects.get().notification_type, 'REPLY') + + def test_handle_favorite(self): ''' fav a status ''' activity = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'http://example.com/fav/1', + 'id': 'https://example.com/fav/1', 'actor': 'https://example.com/users/rat', 'published': 'Mon, 25 May 2020 19:31:20 GMT', - 'object': 'http://local.com/status/1', + 'object': 'https://example.com/status/1', } incoming.handle_favorite(activity) - fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1') + fav = models.Favorite.objects.get(remote_id='https://example.com/fav/1') self.assertEqual(fav.status, self.status) - self.assertEqual(fav.remote_id, 'http://example.com/fav/1') + self.assertEqual(fav.remote_id, 'https://example.com/fav/1') self.assertEqual(fav.user, self.remote_user) From 1bcae45145bc819dddcd28dbcbae8843397f7d45 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 12:39:37 -0800 Subject: [PATCH 07/14] Remove notifications related to deleted statuses --- bookwyrm/incoming.py | 3 +- bookwyrm/tests/test_incoming.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index f4f8afbbe..e85760228 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -248,11 +248,12 @@ def handle_delete_status(activity): # is trying to delete a user. return try: - status = models.Status.objects.select_subclasses().get( + status = models.Status.objects.get( remote_id=status_id ) except models.Status.DoesNotExist: return + models.Notification.objects.filter(related_status=status).all().delete() status_builder.delete_status(status) diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 0c8b6967c..cefa99f91 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -1,4 +1,5 @@ ''' test incoming activities ''' +from datetime import datetime import json import pathlib from unittest.mock import patch @@ -310,6 +311,53 @@ class Incoming(TestCase): models.Notification.objects.get().notification_type, 'REPLY') + def test_handle_delete_status(self): + ''' remove a status ''' + self.assertFalse(self.status.deleted) + activity = { + 'type': 'Delete', + 'id': '%s/activity' % self.status.remote_id, + 'actor': self.local_user.remote_id, + 'object': {'id': self.status.remote_id}, + } + incoming.handle_delete_status(activity) + # deletion doens't remove the status, it turns it into a tombstone + status = models.Status.objects.get() + self.assertTrue(status.deleted) + self.assertIsInstance(status.deleted_date, datetime) + + + def test_handle_delete_status_notifications(self): + ''' remove a status with related notifications ''' + models.Notification.objects.create( + related_status=self.status, + user=self.local_user, + notification_type='MENTION' + ) + # this one is innocent, don't delete it + notif = models.Notification.objects.create( + user=self.local_user, + notification_type='MENTION' + ) + self.assertFalse(self.status.deleted) + self.assertEqual(models.Notification.objects.count(), 2) + activity = { + 'type': 'Delete', + 'id': '%s/activity' % self.status.remote_id, + 'actor': self.local_user.remote_id, + 'object': {'id': self.status.remote_id}, + } + incoming.handle_delete_status(activity) + # deletion doens't remove the status, it turns it into a tombstone + status = models.Status.objects.get() + self.assertTrue(status.deleted) + self.assertIsInstance(status.deleted_date, datetime) + + # notifications should be truly deleted + self.assertEqual(models.Notification.objects.count(), 1) + self.assertEqual(models.Notification.objects.get(), notif) + + def test_handle_favorite(self): ''' fav a status ''' activity = { @@ -326,3 +374,23 @@ class Incoming(TestCase): self.assertEqual(fav.status, self.status) self.assertEqual(fav.remote_id, 'https://example.com/fav/1') self.assertEqual(fav.user, self.remote_user) + + def test_handle_unfavorite(self): + ''' fav a status ''' + activity = { + 'id': 'https://example.com/fav/1#undo', + 'type': 'Undo', + 'object': { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': 'https://example.com/fav/1', + 'actor': 'https://example.com/users/rat', + 'published': 'Mon, 25 May 2020 19:31:20 GMT', + 'object': 'https://example.com/status/1', + } + } + models.Favorite.objects.create( + status=self.status, user=self.remote_user) + self.assertEqual(models.Favorite.objects.count(), 1) + + incoming.handle_unfavorite(activity) + self.assertEqual(models.Favorite.objects.count(), 0) From 8787599692ed35e3a5227854a3704aa7e9e8dc22 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Mon, 14 Dec 2020 13:04:07 -0800 Subject: [PATCH 08/14] tests unfavorite --- bookwyrm/tests/test_incoming.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index cefa99f91..06aeac88f 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -12,6 +12,7 @@ from django.test.client import RequestFactory from bookwyrm import models, incoming +#pylint: disable=too-many-public-methods class Incoming(TestCase): ''' a lot here: all handlers for receiving activitypub requests ''' def setUp(self): @@ -385,11 +386,13 @@ class Incoming(TestCase): 'id': 'https://example.com/fav/1', 'actor': 'https://example.com/users/rat', 'published': 'Mon, 25 May 2020 19:31:20 GMT', - 'object': 'https://example.com/status/1', + 'object': 'https://example.com/fav/1', } } models.Favorite.objects.create( - status=self.status, user=self.remote_user) + status=self.status, + user=self.remote_user, + remote_id='https://example.com/fav/1') self.assertEqual(models.Favorite.objects.count(), 1) incoming.handle_unfavorite(activity) From 2ef4df41b43ca04a6ac243ac6034509def53987c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 11:15:06 -0800 Subject: [PATCH 09/14] Fixes boosts --- bookwyrm/models/fields.py | 8 +++++++- bookwyrm/models/status.py | 12 +++++++++++ bookwyrm/tests/test_incoming.py | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 34f90103b..747167f08 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -42,7 +42,13 @@ class ActivitypubFieldMixin: def set_field_from_activity(self, instance, data): ''' helper function for assinging a value to the field ''' - value = getattr(data, self.get_activitypub_field()) + try: + value = getattr(data, self.get_activitypub_field()) + except AttributeError: + # masssively hack-y workaround for boosts + if self.get_activitypub_field() != 'attributedTo': + raise + value = getattr(data, 'actor') formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: return diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 0bf897dc1..f0cd3c1d2 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -218,6 +218,18 @@ class Boost(Status): activitypub_field='object', ) + def __init__(self, *args, **kwargs): + ''' the user field is "actor" here instead of "attributedTo" ''' + super().__init__(*args, **kwargs) + + reserve_fields = ['user', 'boosted_status'] + self.simple_fields = [f for f in self.simple_fields if \ + f.name in reserve_fields] + self.activity_fields = self.simple_fields + self.many_to_many_fields = [] + self.image_fields = [] + self.deserialize_reverse_fields = [] + activity_serializer = activitypub.Boost # This constraint can't work as it would cross tables. diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 06aeac88f..34375321c 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -256,7 +256,9 @@ class Incoming(TestCase): models.Edition.objects.create( title='Test Book', remote_id='https://example.com/book/1') activity = {'object': status_data, 'type': 'Create'} + incoming.handle_create(activity) + status = models.Quotation.objects.get() self.assertEqual( status.remote_id, 'https://example.com/user/mouse/quotation/13') @@ -397,3 +399,36 @@ class Incoming(TestCase): incoming.handle_unfavorite(activity) self.assertEqual(models.Favorite.objects.count(), 0) + + + def test_handle_boost(self): + ''' boost a status ''' + self.assertEqual(models.Notification.objects.count(), 0) + activity = { + 'type': 'Announce', + 'id': '%s/boost' % self.status.remote_id, + 'actor': self.remote_user.remote_id, + 'object': self.status.to_activity(), + } + incoming.handle_boost(activity) + boost = models.Boost.objects.get() + self.assertEqual(boost.boosted_status, self.status) + notification = models.Notification.objects.get() + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.related_status, self.status) + + + def test_handle_unboost(self): + ''' undo a boost ''' + activity = { + 'type': 'Undo', + 'object': { + 'type': 'Announce', + 'id': '%s/boost' % self.status.remote_id, + 'actor': self.local_user.remote_id, + 'object': self.status.to_activity(), + } + } + models.Boost.objects.create( + boosted_status=self.status, user=self.remote_user) + incoming.handle_unboost(activity) From ee2121095c7163e08cb80ae4980e84fcfa107556 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 12:39:09 -0800 Subject: [PATCH 10/14] Separate update editon and work functions --- bookwyrm/incoming.py | 12 ++++++--- bookwyrm/models/fields.py | 2 ++ bookwyrm/tests/test_incoming.py | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index e85760228..9c8c28878 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -66,8 +66,8 @@ def shared_inbox(request): }, 'Update': { 'Person': handle_update_user, - 'Edition': handle_update_book, - 'Work': handle_update_book, + 'Edition': handle_update_edition, + 'Work': handle_update_work, }, } activity_type = activity['type'] @@ -338,6 +338,12 @@ def handle_update_user(activity): @app.task -def handle_update_book(activity): +def handle_update_edition(activity): ''' a remote instance changed a book (Document) ''' activitypub.Edition(**activity['object']).to_model(models.Edition) + + +@app.task +def handle_update_work(activity): + ''' a remote instance changed a book (Document) ''' + activitypub.Work(**activity['object']).to_model(models.Work) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 747167f08..f6142e374 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -237,6 +237,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): def field_from_activity(self, value): items = [] + if value is None or value is MISSING: + return [] for remote_id in value: try: validate_remote_id(remote_id) diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index 34375321c..d8e85ef2a 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -432,3 +432,50 @@ class Incoming(TestCase): models.Boost.objects.create( boosted_status=self.status, user=self.remote_user) incoming.handle_unboost(activity) + + def test_handle_update_user(self): + ''' update an existing user ''' + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/ap_user.json') + userdata = json.loads(datafile.read_bytes()) + del userdata['icon'] + self.assertEqual(self.local_user.name, '') + incoming.handle_update_user({'object': userdata}) + user = models.User.objects.get(id=self.local_user.id) + self.assertEqual(user.name, 'MOUSE?? MOUSE!!') + + + def test_handle_update_edition(self): + ''' update an existing edition ''' + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/fr_edition.json') + bookdata = json.loads(datafile.read_bytes()) + + book = models.Edition.objects.create( + title='Test Book', remote_id='https://bookwyrm.social/book/5989') + + del bookdata['authors'] + self.assertEqual(book.title, 'Test Book') + with patch( + 'bookwyrm.activitypub.base_activity.set_related_field.delay'): + incoming.handle_update_edition({'object': bookdata}) + book = models.Edition.objects.get(id=book.id) + self.assertEqual(book.title, 'Piranesi') + + + def test_handle_update_work(self): + ''' update an existing edition ''' + datafile = pathlib.Path(__file__).parent.joinpath( + 'data/fr_work.json') + bookdata = json.loads(datafile.read_bytes()) + + book = models.Work.objects.create( + title='Test Book', remote_id='https://bookwyrm.social/book/5988') + + del bookdata['authors'] + self.assertEqual(book.title, 'Test Book') + with patch( + 'bookwyrm.activitypub.base_activity.set_related_field.delay'): + incoming.handle_update_work({'object': bookdata}) + book = models.Work.objects.get(id=book.id) + self.assertEqual(book.title, 'Piranesi') From 710fbc949b95d9e3c616b16a1d3cac8e0796c8d4 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 15:52:22 -0800 Subject: [PATCH 11/14] Better username validator and remove trailing whitespace --- bookwyrm/models/fields.py | 11 +++++++++-- bookwyrm/view_actions.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index f6142e374..b8efc71d0 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -5,7 +5,6 @@ from uuid import uuid4 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 @@ class UsernameField(ActivitypubFieldMixin, models.CharField): _('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 index 7126b1b22..26106190b 100644 --- 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'] From bde75766f2a28b98bf997adb5d370b6cd761aee9 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 16:36:22 -0800 Subject: [PATCH 12/14] test for registration and password reset --- bookwyrm/tests/test_view_actions.py | 239 ++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 bookwyrm/tests/test_view_actions.py diff --git a/bookwyrm/tests/test_view_actions.py b/bookwyrm/tests/test_view_actions.py new file mode 100644 index 000000000..bb0fcdb24 --- /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()) From fabf880a94cefbb9bdbcbeabdd7f9516e68707e5 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 16:50:10 -0800 Subject: [PATCH 13/14] Adds post attribute to resolve book endpoint --- bookwyrm/view_actions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 26106190b..f193e1277 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -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') From 948e938040bd2dfa0aadb7959a2ab717b7553a89 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Tue, 15 Dec 2020 18:16:33 -0800 Subject: [PATCH 14/14] Handles outdated Add with Book type should be edition. --- bookwyrm/models/fields.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index b8efc71d0..5e12f5d56 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -283,6 +283,8 @@ class TagField(ManyToManyField): for link_json in value: link = activitypub.Link(**link_json) tag_type = link.type if link.type != 'Mention' else 'Person' + if tag_type == 'Book': + tag_type = 'Edition' if tag_type != self.related_model.activity_serializer.type: # tags can contain multiple types continue