Merge pull request #724 from mouse-reeve/inbox-boost-bug

Fixes exceptions in handling incoming boosts
This commit is contained in:
Mouse Reeve 2021-03-13 08:40:13 -08:00 committed by GitHub
commit 40ab84bc15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 134 additions and 43 deletions

View file

@ -258,10 +258,9 @@ def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
# load the data and create the object # load the data and create the object
try: try:
data = get_data(remote_id) data = get_data(remote_id)
except (ConnectorException, ConnectionError): except ConnectorException:
raise ActivitySerializerError( raise ActivitySerializerError(
"Could not connect to host for remote_id in %s model: %s" "Could not connect to host for remote_id in: %s" % (remote_id)
% (model.__name__, remote_id)
) )
# determine the model implicitly, if not provided # determine the model implicitly, if not provided
if not model: if not model:

View file

@ -70,6 +70,9 @@ class Undo(Verb):
if self.object.type == "Follow": if self.object.type == "Follow":
model = apps.get_model("bookwyrm.UserFollows") model = apps.get_model("bookwyrm.UserFollows")
obj = self.object.to_model(model=model, save=False, allow_create=False) obj = self.object.to_model(model=model, save=False, allow_create=False)
if not obj:
# if we don't have the object, we can't undo it. happens a lot with boosts
return
obj.delete() obj.delete()

View file

@ -244,7 +244,7 @@ def get_data(url):
"User-Agent": settings.USER_AGENT, "User-Agent": settings.USER_AGENT,
}, },
) )
except (RequestError, SSLError) as e: except (RequestError, SSLError, ConnectionError) as e:
logger.exception(e) logger.exception(e)
raise ConnectorException() raise ConnectorException()

View file

@ -456,8 +456,8 @@ def broadcast_task(sender_id, activity, recipients):
for recipient in recipients: for recipient in recipients:
try: try:
sign_and_send(sender, activity, recipient) sign_and_send(sender, activity, recipient)
except (HTTPError, SSLError, ConnectionError) as e: except (HTTPError, SSLError, ConnectionError):
logger.exception(e) pass
def sign_and_send(sender, data, destination): def sign_and_send(sender, data, destination):

View file

@ -115,13 +115,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): def ignore_activity(cls, activity):
""" keep notes if they are replies to existing statuses """ """ keep notes if they are replies to existing statuses """
if activity.type == "Announce": if activity.type == "Announce":
# keep it if the booster or the boosted are local try:
boosted = activitypub.resolve_remote_id(activity.object, save=False) boosted = activitypub.resolve_remote_id(activity.object, save=False)
except activitypub.ActivitySerializerError:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
return cls.ignore_activity(boosted.to_activity_dataclass()) return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type # keep if it if it's a custom type
if activity.type != "Note": if activity.type != "Note":
return False return False
# keep it if it's a reply to an existing status
if cls.objects.filter(remote_id=activity.inReplyTo).exists(): if cls.objects.filter(remote_id=activity.inReplyTo).exists():
return False return False

View file

@ -8,19 +8,31 @@ from django.core.files.base import ContentFile
from django.db import IntegrityError from django.db import IntegrityError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
import responses
from bookwyrm import models, settings from bookwyrm import activitypub, models, settings
# pylint: disable=too-many-public-methods
@patch("bookwyrm.models.Status.broadcast") @patch("bookwyrm.models.Status.broadcast")
class Status(TestCase): class Status(TestCase):
""" lotta types of statuses """ """ lotta types of statuses """
def setUp(self): def setUp(self):
""" useful things for creating a status """ """ useful things for creating a status """
self.user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse" "mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
) )
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.book = models.Edition.objects.create(title="Test Edition") self.book = models.Edition.objects.create(title="Test Edition")
image_file = pathlib.Path(__file__).parent.joinpath( image_file = pathlib.Path(__file__).parent.joinpath(
@ -34,22 +46,22 @@ class Status(TestCase):
def test_status_generated_fields(self, _): def test_status_generated_fields(self, _):
""" setting remote id """ """ setting remote id """
status = models.Status.objects.create(content="bleh", user=self.user) status = models.Status.objects.create(content="bleh", user=self.local_user)
expected_id = "https://%s/user/mouse/status/%d" % (settings.DOMAIN, status.id) expected_id = "https://%s/user/mouse/status/%d" % (settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id) self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, "public") self.assertEqual(status.privacy, "public")
def test_replies(self, _): def test_replies(self, _):
""" get a list of replies """ """ get a list of replies """
parent = models.Status.objects.create(content="hi", user=self.user) parent = models.Status.objects.create(content="hi", user=self.local_user)
child = models.Status.objects.create( child = models.Status.objects.create(
content="hello", reply_parent=parent, user=self.user content="hello", reply_parent=parent, user=self.local_user
) )
models.Review.objects.create( models.Review.objects.create(
content="hey", reply_parent=parent, user=self.user, book=self.book content="hey", reply_parent=parent, user=self.local_user, book=self.book
) )
models.Status.objects.create( models.Status.objects.create(
content="hi hello", reply_parent=child, user=self.user content="hi hello", reply_parent=child, user=self.local_user
) )
replies = models.Status.replies(parent) replies = models.Status.replies(parent)
@ -75,15 +87,15 @@ class Status(TestCase):
def test_to_replies(self, _): def test_to_replies(self, _):
""" activitypub replies collection """ """ activitypub replies collection """
parent = models.Status.objects.create(content="hi", user=self.user) parent = models.Status.objects.create(content="hi", user=self.local_user)
child = models.Status.objects.create( child = models.Status.objects.create(
content="hello", reply_parent=parent, user=self.user content="hello", reply_parent=parent, user=self.local_user
) )
models.Review.objects.create( models.Review.objects.create(
content="hey", reply_parent=parent, user=self.user, book=self.book content="hey", reply_parent=parent, user=self.local_user, book=self.book
) )
models.Status.objects.create( models.Status.objects.create(
content="hi hello", reply_parent=child, user=self.user content="hi hello", reply_parent=child, user=self.local_user
) )
replies = parent.to_replies() replies = parent.to_replies()
@ -92,7 +104,9 @@ class Status(TestCase):
def test_status_to_activity(self, _): def test_status_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create(content="test content", user=self.user) status = models.Status.objects.create(
content="test content", user=self.local_user
)
activity = status.to_activity() activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "Note") self.assertEqual(activity["type"], "Note")
@ -103,7 +117,7 @@ class Status(TestCase):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create( status = models.Status.objects.create(
content="test content", content="test content",
user=self.user, user=self.local_user,
deleted=True, deleted=True,
deleted_date=timezone.now(), deleted_date=timezone.now(),
) )
@ -114,7 +128,9 @@ class Status(TestCase):
def test_status_to_pure_activity(self, _): def test_status_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create(content="test content", user=self.user) status = models.Status.objects.create(
content="test content", user=self.local_user
)
activity = status.to_activity(pure=True) activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "Note") self.assertEqual(activity["type"], "Note")
@ -125,10 +141,10 @@ class Status(TestCase):
def test_generated_note_to_activity(self, _): def test_generated_note_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.GeneratedNote.objects.create( status = models.GeneratedNote.objects.create(
content="test content", user=self.user content="test content", user=self.local_user
) )
status.mention_books.set([self.book]) status.mention_books.set([self.book])
status.mention_users.set([self.user]) status.mention_users.set([self.local_user])
activity = status.to_activity() activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "GeneratedNote") self.assertEqual(activity["type"], "GeneratedNote")
@ -139,10 +155,10 @@ class Status(TestCase):
def test_generated_note_to_pure_activity(self, _): def test_generated_note_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.GeneratedNote.objects.create( status = models.GeneratedNote.objects.create(
content="test content", user=self.user content="test content", user=self.local_user
) )
status.mention_books.set([self.book]) status.mention_books.set([self.book])
status.mention_users.set([self.user]) status.mention_users.set([self.local_user])
activity = status.to_activity(pure=True) activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
self.assertEqual( self.assertEqual(
@ -163,7 +179,7 @@ class Status(TestCase):
def test_comment_to_activity(self, _): def test_comment_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Comment.objects.create( status = models.Comment.objects.create(
content="test content", user=self.user, book=self.book content="test content", user=self.local_user, book=self.book
) )
activity = status.to_activity() activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
@ -174,7 +190,7 @@ class Status(TestCase):
def test_comment_to_pure_activity(self, _): def test_comment_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Comment.objects.create( status = models.Comment.objects.create(
content="test content", user=self.user, book=self.book content="test content", user=self.local_user, book=self.book
) )
activity = status.to_activity(pure=True) activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id) self.assertEqual(activity["id"], status.remote_id)
@ -196,7 +212,7 @@ class Status(TestCase):
status = models.Quotation.objects.create( status = models.Quotation.objects.create(
quote="a sickening sense", quote="a sickening sense",
content="test content", content="test content",
user=self.user, user=self.local_user,
book=self.book, book=self.book,
) )
activity = status.to_activity() activity = status.to_activity()
@ -211,7 +227,7 @@ class Status(TestCase):
status = models.Quotation.objects.create( status = models.Quotation.objects.create(
quote="a sickening sense", quote="a sickening sense",
content="test content", content="test content",
user=self.user, user=self.local_user,
book=self.book, book=self.book,
) )
activity = status.to_activity(pure=True) activity = status.to_activity(pure=True)
@ -235,7 +251,7 @@ class Status(TestCase):
name="Review name", name="Review name",
content="test content", content="test content",
rating=3, rating=3,
user=self.user, user=self.local_user,
book=self.book, book=self.book,
) )
activity = status.to_activity() activity = status.to_activity()
@ -252,7 +268,7 @@ class Status(TestCase):
name="Review name", name="Review name",
content="test content", content="test content",
rating=3, rating=3,
user=self.user, user=self.local_user,
book=self.book, book=self.book,
) )
activity = status.to_activity(pure=True) activity = status.to_activity(pure=True)
@ -275,30 +291,34 @@ class Status(TestCase):
def fav_broadcast_mock(_, activity, user): def fav_broadcast_mock(_, activity, user):
""" ok """ """ ok """
self.assertEqual(user.remote_id, self.user.remote_id) self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Like") self.assertEqual(activity["type"], "Like")
models.Favorite.broadcast = fav_broadcast_mock models.Favorite.broadcast = fav_broadcast_mock
status = models.Status.objects.create(content="test content", user=self.user) status = models.Status.objects.create(
fav = models.Favorite.objects.create(status=status, user=self.user) content="test content", user=self.local_user
)
fav = models.Favorite.objects.create(status=status, user=self.local_user)
# can't fav a status twice # can't fav a status twice
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
models.Favorite.objects.create(status=status, user=self.user) models.Favorite.objects.create(status=status, user=self.local_user)
activity = fav.to_activity() activity = fav.to_activity()
self.assertEqual(activity["type"], "Like") self.assertEqual(activity["type"], "Like")
self.assertEqual(activity["actor"], self.user.remote_id) self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"], status.remote_id) self.assertEqual(activity["object"], status.remote_id)
models.Favorite.broadcast = real_broadcast models.Favorite.broadcast = real_broadcast
def test_boost(self, _): def test_boost(self, _):
""" boosting, this one's a bit fussy """ """ boosting, this one's a bit fussy """
status = models.Status.objects.create(content="test content", user=self.user) status = models.Status.objects.create(
boost = models.Boost.objects.create(boosted_status=status, user=self.user) content="test content", user=self.local_user
)
boost = models.Boost.objects.create(boosted_status=status, user=self.local_user)
activity = boost.to_activity() activity = boost.to_activity()
self.assertEqual(activity["actor"], self.user.remote_id) self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"], status.remote_id) self.assertEqual(activity["object"], status.remote_id)
self.assertEqual(activity["type"], "Announce") self.assertEqual(activity["type"], "Announce")
self.assertEqual(activity, boost.to_activity(pure=True)) self.assertEqual(activity, boost.to_activity(pure=True))
@ -306,18 +326,20 @@ class Status(TestCase):
def test_notification(self, _): def test_notification(self, _):
""" a simple model """ """ a simple model """
notification = models.Notification.objects.create( notification = models.Notification.objects.create(
user=self.user, notification_type="FAVORITE" user=self.local_user, notification_type="FAVORITE"
) )
self.assertFalse(notification.read) self.assertFalse(notification.read)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
models.Notification.objects.create( models.Notification.objects.create(
user=self.user, notification_type="GLORB" user=self.local_user, notification_type="GLORB"
) )
def test_create_broadcast(self, broadcast_mock): def test_create_broadcast(self, broadcast_mock):
""" should send out two verions of a status on create """ """ should send out two verions of a status on create """
models.Comment.objects.create(content="hi", user=self.user, book=self.book) models.Comment.objects.create(
content="hi", user=self.local_user, book=self.book
)
self.assertEqual(broadcast_mock.call_count, 2) self.assertEqual(broadcast_mock.call_count, 2)
pure_call = broadcast_mock.call_args_list[0] pure_call = broadcast_mock.call_args_list[0]
bw_call = broadcast_mock.call_args_list[1] bw_call = broadcast_mock.call_args_list[1]
@ -332,3 +354,48 @@ class Status(TestCase):
args = bw_call[0][0] args = bw_call[0][0]
self.assertEqual(args["type"], "Create") self.assertEqual(args["type"], "Create")
self.assertEqual(args["object"]["type"], "Comment") self.assertEqual(args["object"]["type"], "Comment")
def test_recipients_with_mentions(self, _):
""" get recipients to broadcast a status """
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user
)
status.mention_users.add(self.remote_user)
self.assertEqual(status.recipients, [self.remote_user])
def test_recipients_with_reply_parent(self, _):
""" get recipients to broadcast a status """
parent_status = models.GeneratedNote.objects.create(
content="test content", user=self.remote_user
)
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user, reply_parent=parent_status
)
self.assertEqual(status.recipients, [self.remote_user])
def test_recipients_with_reply_parent_and_mentions(self, _):
""" get recipients to broadcast a status """
parent_status = models.GeneratedNote.objects.create(
content="test content", user=self.remote_user
)
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user, reply_parent=parent_status
)
status.mention_users.set([self.remote_user])
self.assertEqual(status.recipients, [self.remote_user])
@responses.activate
def test_ignore_activity_boost(self, _):
""" don't bother with most remote statuses """
activity = activitypub.Announce(
id="http://www.faraway.com/boost/12",
actor=self.remote_user.remote_id,
object="http://fish.com/nothing",
)
responses.add(responses.GET, "http://fish.com/nothing", status=404)
self.assertTrue(models.Status.ignore_activity(activity))

View file

@ -563,6 +563,23 @@ class Inbox(TestCase):
} }
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
def test_handle_unboost_unknown_boost(self):
""" undo a boost """
activity = {
"type": "Undo",
"actor": "hi",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {
"type": "Announce",
"id": "http://fake.com/unknown/boost",
"actor": self.remote_user.remote_id,
"object": self.status.remote_id,
},
}
views.inbox.activity_task(activity)
def test_handle_add_book_to_shelf(self): def test_handle_add_book_to_shelf(self):
""" shelving a book """ """ shelving a book """
work = models.Work.objects.create(title="work title") work = models.Work.objects.create(title="work title")