bookwyrm/bookwyrm/tests/models/test_activitypub_mixin.py
Adeodato Simó 31babdfa51
Always prefer shared inboxes when computing receipent lists
This avoids duplicate submissions to remote instances when mentioning
followers (i.e., `POST /user/foo/inbox` followed by `POST /inbox`, which
results in two separate `add_status` tasks, and might generate duplicates
in the target instance).
2024-01-26 06:18:02 -03:00

464 lines
19 KiB
Python

""" testing model activitypub utilities """
from unittest.mock import patch
from collections import namedtuple
from dataclasses import dataclass
import re
from django import db
from django.test import TestCase
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm import models
from bookwyrm.models import base_model
from bookwyrm.models.activitypub_mixin import (
ActivitypubMixin,
ActivityMixin,
broadcast_task,
ObjectMixin,
OrderedCollectionMixin,
to_ordered_collection_page,
)
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=invalid-name,too-many-public-methods
@patch("bookwyrm.activitystreams.add_status_task.delay")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
class ActivitypubMixins(TestCase):
"""functionality shared across models"""
@classmethod
def setUpTestData(self): # pylint: disable=bad-classmethod-argument
"""shared data"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
)
self.local_user.remote_id = "http://example.com/a/b"
self.local_user.save(broadcast=False, update_fields=["remote_id"])
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",
)
def setUp(self):
"""test data"""
self.object_mock = {
"to": "to field",
"cc": "cc field",
"content": "hi",
"id": "bip",
"type": "Test",
"published": "2020-12-04T17:52:22.623807+00:00",
}
def test_to_activity(self, *_):
"""model to ActivityPub json"""
@dataclass(init=False)
class TestActivity(ActivityObject):
"""real simple mock"""
type: str = "Test"
class TestModel(ActivitypubMixin, base_model.BookWyrmModel):
"""real simple mock model because BookWyrmModel is abstract"""
instance = TestModel()
instance.remote_id = "https://www.example.com/test"
instance.activity_serializer = TestActivity
activity = instance.to_activity()
self.assertIsInstance(activity, dict)
self.assertEqual(activity["id"], "https://www.example.com/test")
self.assertEqual(activity["type"], "Test")
def test_find_existing_by_remote_id(self, *_):
"""attempt to match a remote id to an object in the db"""
# uses a different remote id scheme
# this isn't really part of this test directly but it's helpful to state
book = models.Edition.objects.create(
title="Test Edition", remote_id="http://book.com/book"
)
self.assertEqual(book.origin_id, "http://book.com/book")
self.assertNotEqual(book.remote_id, "http://book.com/book")
# uses subclasses
models.Comment.objects.create(
user=self.local_user,
content="test status",
book=book,
remote_id="https://comment.net",
)
result = models.User.find_existing_by_remote_id("hi")
self.assertIsNone(result)
result = models.User.find_existing_by_remote_id("http://example.com/a/b")
self.assertEqual(result, self.local_user)
# test using origin id
result = models.Edition.find_existing_by_remote_id("http://book.com/book")
self.assertEqual(result, book)
# test subclass match
result = models.Status.find_existing_by_remote_id("https://comment.net")
def test_find_existing(self, *_):
"""match a blob of data to a model"""
book = models.Edition.objects.create(
title="Test edition",
openlibrary_key="OL1234",
)
result = models.Edition.find_existing({"openlibraryKey": "OL1234"})
self.assertEqual(result, book)
def test_find_existing_with_id(self, *_):
"""make sure that an "id" field won't produce a match"""
book = models.Edition.objects.create(title="Test edition")
result = models.Edition.find_existing({"id": book.id})
self.assertIsNone(result)
def test_find_existing_with_id_and_match(self, *_):
"""make sure that an "id" field won't produce a match"""
book = models.Edition.objects.create(title="Test edition")
matching_book = models.Edition.objects.create(
title="Another test edition", openlibrary_key="OL1234"
)
result = models.Edition.find_existing(
{"id": book.id, "openlibraryKey": "OL1234"}
)
self.assertEqual(result, matching_book)
def test_get_recipients_public_object(self, *_):
"""determines the recipients for an object's broadcast"""
MockSelf = namedtuple("Self", ("privacy"))
mock_self = MockSelf("public")
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
def test_get_recipients_public_user_object_no_followers(self, *_):
"""determines the recipients for a user's object broadcast"""
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 0)
def test_get_recipients_public_user_object(self, *_):
"""determines the recipients for a user's object broadcast"""
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
self.local_user.followers.add(self.remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
def test_get_recipients_public_user_object_with_mention(self, *_):
"""determines the recipients for a user's object broadcast"""
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
self.local_user.followers.add(self.remote_user)
with patch("bookwyrm.models.user.set_remote_server.delay"):
another_remote_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.com",
"nutriaword",
local=False,
remote_id="https://example.com/users/nutria",
inbox="https://example.com/users/nutria/inbox",
outbox="https://example.com/users/nutria/outbox",
)
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
mock_self = MockSelf("public", self.local_user, [another_remote_user])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2)
self.assertTrue(another_remote_user.inbox in recipients)
self.assertTrue(self.remote_user.inbox in recipients)
def test_get_recipients_direct(self, *_):
"""determines the recipients for a user's object broadcast"""
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
self.local_user.followers.add(self.remote_user)
with patch("bookwyrm.models.user.set_remote_server.delay"):
another_remote_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.com",
"nutriaword",
local=False,
remote_id="https://example.com/users/nutria",
inbox="https://example.com/users/nutria/inbox",
outbox="https://example.com/users/nutria/outbox",
)
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
mock_self = MockSelf("direct", self.local_user, [another_remote_user])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], another_remote_user.inbox)
def test_get_recipients_combine_inboxes(self, *_):
"""should combine users with the same shared_inbox"""
self.remote_user.shared_inbox = "http://example.com/inbox"
self.remote_user.save(broadcast=False, update_fields=["shared_inbox"])
with patch("bookwyrm.models.user.set_remote_server.delay"):
another_remote_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.com",
"nutriaword",
local=False,
remote_id="https://example.com/users/nutria",
inbox="https://example.com/users/nutria/inbox",
shared_inbox="http://example.com/inbox",
outbox="https://example.com/users/nutria/outbox",
)
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
mock_self = MockSelf("public", self.local_user, [])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertCountEqual(recipients, ["http://example.com/inbox"])
# should also work with recipient that is a follower
mock_self.recipients.append(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertCountEqual(recipients, ["http://example.com/inbox"])
def test_get_recipients_software(self, *_):
"""should differentiate between bookwyrm and other remote users"""
with patch("bookwyrm.models.user.set_remote_server.delay"):
another_remote_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.com",
"nutriaword",
local=False,
remote_id="https://example.com/users/nutria",
inbox="https://example.com/users/nutria/inbox",
outbox="https://example.com/users/nutria/outbox",
bookwyrm_user=False,
)
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2)
recipients = ActivitypubMixin.get_recipients(mock_self, software="bookwyrm")
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
recipients = ActivitypubMixin.get_recipients(mock_self, software="other")
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], another_remote_user.inbox)
# ObjectMixin
def test_object_save_create(self, *_):
"""should save uneventfully when broadcast is disabled"""
class Success(Exception):
"""this means we got to the right method"""
class ObjectModel(ObjectMixin, base_model.BookWyrmModel):
"""real simple mock model because BookWyrmModel is abstract"""
user = models.fields.ForeignKey("User", on_delete=db.models.CASCADE)
def save(self, *args, **kwargs):
with patch("django.db.models.Model.save"):
super().save(*args, **kwargs)
def broadcast(
self, activity, sender, **kwargs
): # pylint: disable=arguments-differ
"""do something"""
raise Success()
def to_create_activity(self, user): # pylint: disable=arguments-differ
return {}
with self.assertRaises(Success):
ObjectModel(user=self.local_user).save()
ObjectModel(user=self.remote_user).save()
ObjectModel(user=self.local_user).save(broadcast=False)
ObjectModel(user=None).save()
def test_object_save_update(self, *_):
"""should save uneventfully when broadcast is disabled"""
class Success(Exception):
"""this means we got to the right method"""
class UpdateObjectModel(ObjectMixin, base_model.BookWyrmModel):
"""real simple mock model because BookWyrmModel is abstract"""
user = models.fields.ForeignKey("User", on_delete=db.models.CASCADE)
last_edited_by = models.fields.ForeignKey(
"User", on_delete=db.models.CASCADE
)
def save(self, *args, **kwargs):
with patch("django.db.models.Model.save"):
super().save(*args, **kwargs)
def to_update_activity(self, user):
raise Success()
with self.assertRaises(Success):
UpdateObjectModel(id=1, user=self.local_user).save()
with self.assertRaises(Success):
UpdateObjectModel(id=1, last_edited_by=self.local_user).save()
def test_object_save_delete(self, *_):
"""should create delete activities when objects are deleted by flag"""
class ActivitySuccess(Exception):
"""this means we got to the right method"""
class DeletableObjectModel(ObjectMixin, base_model.BookWyrmModel):
"""real simple mock model because BookWyrmModel is abstract"""
user = models.fields.ForeignKey("User", on_delete=db.models.CASCADE)
deleted = models.fields.BooleanField()
def save(self, *args, **kwargs):
with patch("django.db.models.Model.save"):
super().save(*args, **kwargs)
def to_delete_activity(self, user):
raise ActivitySuccess()
with self.assertRaises(ActivitySuccess):
DeletableObjectModel(id=1, user=self.local_user, deleted=True).save()
def test_to_delete_activity(self, *_):
"""wrapper for Delete activity"""
MockSelf = namedtuple("Self", ("remote_id", "to_activity"))
mock_self = MockSelf(
"https://example.com/status/1", lambda *args: self.object_mock
)
activity = ObjectMixin.to_delete_activity(mock_self, self.local_user)
self.assertEqual(activity["id"], "https://example.com/status/1/activity")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["type"], "Delete")
self.assertEqual(activity["to"], [f"{self.local_user.remote_id}/followers"])
self.assertEqual(
activity["cc"], ["https://www.w3.org/ns/activitystreams#Public"]
)
def test_to_update_activity(self, *_):
"""ditto above but for Update"""
MockSelf = namedtuple("Self", ("remote_id", "to_activity"))
mock_self = MockSelf(
"https://example.com/status/1", lambda *args: self.object_mock
)
activity = ObjectMixin.to_update_activity(mock_self, self.local_user)
self.assertIsNotNone(
re.match(r"^https:\/\/example\.com\/status\/1#update\/.*", activity["id"])
)
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["type"], "Update")
self.assertEqual(
activity["to"], ["https://www.w3.org/ns/activitystreams#Public"]
)
self.assertIsInstance(activity["object"], dict)
def test_to_undo_activity(self, *_):
"""and again, for Undo"""
MockSelf = namedtuple("Self", ("remote_id", "to_activity", "user"))
mock_self = MockSelf(
"https://example.com/status/1",
lambda *args: self.object_mock,
self.local_user,
)
activity = ActivityMixin.to_undo_activity(mock_self)
self.assertEqual(activity["id"], "https://example.com/status/1#undo")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["type"], "Undo")
self.assertIsInstance(activity["object"], dict)
def test_to_ordered_collection_page(self, *_):
"""make sure the paged results of an ordered collection work"""
self.assertEqual(PAGE_LENGTH, 15)
models.Status.objects.bulk_create(
models.Status(
user=self.local_user,
content=f"test status {number}",
)
for number in range(2 * PAGE_LENGTH)
)
page_1 = to_ordered_collection_page(
models.Status.objects.all(), "http://fish.com/", page=1
)
self.assertEqual(page_1.partOf, "http://fish.com/")
self.assertEqual(page_1.id, "http://fish.com/?page=1")
self.assertEqual(page_1.next, "http://fish.com/?page=2")
self.assertEqual(page_1.orderedItems[0]["content"], "<p>test status 29</p>")
self.assertEqual(page_1.orderedItems[1]["content"], "<p>test status 28</p>")
page_2 = to_ordered_collection_page(
models.Status.objects.all(), "http://fish.com/", page=2
)
self.assertEqual(page_2.partOf, "http://fish.com/")
self.assertEqual(page_2.id, "http://fish.com/?page=2")
self.assertEqual(page_2.orderedItems[0]["content"], "<p>test status 14</p>")
self.assertEqual(page_2.orderedItems[-1]["content"], "<p>test status 0</p>")
def test_to_ordered_collection(self, *_):
"""convert a queryset into an ordered collection object"""
self.assertEqual(PAGE_LENGTH, 15)
models.Status.objects.bulk_create(
models.Status(
user=self.local_user,
content=f"test status {number}",
)
for number in range(2 * PAGE_LENGTH)
)
MockSelf = namedtuple("Self", ("remote_id"))
mock_self = MockSelf("")
collection = OrderedCollectionMixin.to_ordered_collection(
mock_self, models.Status.objects.all(), remote_id="http://fish.com/"
)
self.assertEqual(collection.totalItems, 30)
self.assertEqual(collection.first, "http://fish.com/?page=1")
self.assertEqual(collection.last, "http://fish.com/?page=2")
page_2 = OrderedCollectionMixin.to_ordered_collection(
mock_self, models.Status.objects.all(), remote_id="http://fish.com/", page=2
)
self.assertEqual(page_2.partOf, "http://fish.com/")
self.assertEqual(page_2.id, "http://fish.com/?page=2")
self.assertEqual(page_2.orderedItems[0]["content"], "<p>test status 14</p>")
self.assertEqual(page_2.orderedItems[-1]["content"], "<p>test status 0</p>")
def test_broadcast_task(self, *_):
"""Should be calling asyncio"""
recipients = [
"https://instance.example/user/inbox",
"https://instance.example/okay/inbox",
]
with patch("bookwyrm.models.activitypub_mixin.asyncio.run") as mock:
broadcast_task(self.local_user.id, {}, recipients)
self.assertTrue(mock.called)
self.assertEqual(mock.call_count, 1)