diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 5fc849d6..afa9cf2b 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -8,6 +8,6 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - uses: psf/black@20.8b1 + - uses: psf/black@stable with: args: ". --check -l 80 -S" diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 4ab9f08e..9231bd95 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -23,6 +23,7 @@ class Person(ActivityObject): inbox: str publicKey: PublicKey followers: str = None + following: str = None outbox: str = None endpoints: Dict = None name: str = None diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 00b5c5c9..2483cc62 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -179,7 +179,11 @@ class AbstractConnector(AbstractMinimalConnector): data = get_data(remote_id) mapped_data = dict_from_mappings(data, self.author_mappings) - activity = activitypub.Author(**mapped_data) + try: + activity = activitypub.Author(**mapped_data) + except activitypub.ActivitySerializerError: + return None + # this will dedupe return activity.to_model(model=models.Author) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index caf6bcbe..53198c0a 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,5 +1,6 @@ """ interface with whatever connectors the app has """ import importlib +import logging import re from urllib.parse import urlparse @@ -11,6 +12,8 @@ from requests import HTTPError from bookwyrm import models from bookwyrm.tasks import app +logger = logging.getLogger(__name__) + class ConnectorException(HTTPError): """ when the connector can't do what was asked """ @@ -37,14 +40,17 @@ def search(query, min_confidence=0.1): else: try: result_set = connector.isbn_search(isbn) - except (HTTPError, ConnectorException): - pass + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + continue # if no isbn search or results, we fallback to generic search if result_set in (None, []): try: result_set = connector.search(query, min_confidence=min_confidence) - except (HTTPError, ConnectorException): + except Exception as e: # pylint: disable=broad-except + # we don't want *any* error to crash the whole search page + logger.exception(e) continue # if the search results look the same, ignore them diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 9be0266c..8ee738eb 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -93,7 +93,10 @@ class Connector(AbstractConnector): # this id is "/authors/OL1234567A" author_id = author_blob["key"] url = "%s%s" % (self.base_url, author_id) - yield self.get_or_create_author(url) + author = self.get_or_create_author(url) + if not author: + continue + yield author def get_cover_url(self, cover_blob, size="L"): """ ask openlibrary for the cover """ diff --git a/bookwyrm/migrations/0062_auto_20210407_1545.py b/bookwyrm/migrations/0062_auto_20210407_1545.py new file mode 100644 index 00000000..3a156637 --- /dev/null +++ b/bookwyrm/migrations/0062_auto_20210407_1545.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.6 on 2021-04-07 15:45 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0061_auto_20210402_1435"), + ] + + operations = [ + migrations.AlterField( + model_name="book", + name="series", + field=bookwyrm.models.fields.TextField( + blank=True, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="book", + name="subtitle", + field=bookwyrm.models.fields.TextField( + blank=True, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="book", + name="title", + field=bookwyrm.models.fields.TextField(max_length=255), + ), + ] diff --git a/bookwyrm/migrations/0063_auto_20210408_1556.py b/bookwyrm/migrations/0063_auto_20210408_1556.py new file mode 100644 index 00000000..750997fb --- /dev/null +++ b/bookwyrm/migrations/0063_auto_20210408_1556.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.6 on 2021-04-08 15:56 + +import bookwyrm.models.fields +import django.contrib.postgres.fields.citext +import django.contrib.postgres.operations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0062_auto_20210407_1545"), + ] + + operations = [ + django.contrib.postgres.operations.CITextExtension(), + migrations.AlterField( + model_name="user", + name="localname", + field=django.contrib.postgres.fields.citext.CICharField( + max_length=255, + null=True, + unique=True, + validators=[bookwyrm.models.fields.validate_localname], + ), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index a253207a..8dce42e4 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -1,5 +1,6 @@ """ activitypub model functionality """ from base64 import b64encode +from collections import namedtuple from functools import reduce import json import operator @@ -25,6 +26,15 @@ from bookwyrm.models.fields import ImageField, ManyToManyField logger = logging.getLogger(__name__) # I tried to separate these classes into mutliple files but I kept getting # circular import errors so I gave up. I'm sure it could be done though! + +PropertyField = namedtuple("PropertyField", ("set_activity_from_field")) + + +def set_activity_from_property_field(activity, obj, field): + """ assign a model property value to the activity json """ + activity[field[1]] = getattr(obj, field[0]) + + class ActivitypubMixin: """ add this mixin for models that are AP serializable """ @@ -52,6 +62,11 @@ class ActivitypubMixin: self.activity_fields = ( self.image_fields + self.many_to_many_fields + self.simple_fields ) + if hasattr(self, "property_fields"): + self.activity_fields += [ + PropertyField(lambda a, o: set_activity_from_property_field(a, o, f)) + for f in self.property_fields + ] # these are separate to avoid infinite recursion issues self.deserialize_reverse_fields = ( @@ -370,7 +385,7 @@ class CollectionItemMixin(ActivitypubMixin): object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Add( - id=self.remote_id, + id=self.get_remote_id(), actor=self.user.remote_id, object=object_field, target=collection_field.remote_id, @@ -381,7 +396,7 @@ class CollectionItemMixin(ActivitypubMixin): object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Remove( - id=self.remote_id, + id=self.get_remote_id(), actor=self.user.remote_id, object=object_field, target=collection_field.remote_id, @@ -430,7 +445,7 @@ def generate_activity(obj): ) in obj.serialize_reverse_fields: related_field = getattr(obj, model_field_name) activity[activity_field_name] = unfurl_related_field( - related_field, sort_field + related_field, sort_field=sort_field ) if not activity.get("id"): @@ -440,7 +455,7 @@ def generate_activity(obj): def unfurl_related_field(related_field, sort_field=None): """ load reverse lookups (like public key owner or Status attachment """ - if hasattr(related_field, "all"): + if sort_field and hasattr(related_field, "all"): return [ unfurl_related_field(i) for i in related_field.order_by(sort_field).all() ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 3204c603..a6824c0a 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -53,14 +53,14 @@ class Book(BookDataModel): connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True) # book/work metadata - title = fields.CharField(max_length=255) + title = fields.TextField(max_length=255) sort_title = fields.CharField(max_length=255, blank=True, null=True) - subtitle = fields.CharField(max_length=255, blank=True, null=True) + subtitle = fields.TextField(max_length=255, blank=True, null=True) description = fields.HtmlField(blank=True, null=True) languages = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) - series = fields.CharField(max_length=255, blank=True, null=True) + series = fields.TextField(max_length=255, blank=True, null=True) series_number = fields.CharField(max_length=255, blank=True, null=True) subjects = fields.ArrayField( models.CharField(max_length=255), blank=True, null=True, default=list diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 33dedc9e..c519f76c 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse from django.apps import apps from django.contrib.auth.models import AbstractUser, Group +from django.contrib.postgres.fields import CICharField from django.core.validators import MinValueValidator from django.db import models from django.utils import timezone @@ -54,7 +55,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): summary = fields.HtmlField(null=True, blank=True) local = models.BooleanField(default=False) bookwyrm_user = fields.BooleanField(default=True) - localname = models.CharField( + localname = CICharField( max_length=255, null=True, unique=True, @@ -112,6 +113,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): ) name_field = "username" + property_fields = [("following_link", "following")] + + @property + def following_link(self): + """ just how to find out the following info """ + return "{:s}/following".format(self.remote_id) @property def alt_text(self): diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 845f81c4..52dbc2b2 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -24,7 +24,7 @@ EMAIL_HOST = env("EMAIL_HOST") EMAIL_PORT = env("EMAIL_PORT", 587) EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = env("EMAIL_USE_TLS", True) +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True) DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN")) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html index a9ce651e..af5d4d69 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit_book.html @@ -88,12 +88,18 @@

{% trans "Metadata" %}

-

{{ form.title }}

+

+ + +

{% for error in form.title.errors %}

{{ error | escape }}

{% endfor %} -

{{ form.subtitle }}

+

+ + +

{% for error in form.subtitle.errors %}

{{ error | escape }}

{% endfor %} @@ -124,7 +130,7 @@

- +

{% for error in form.first_published_date.errors %}

{{ error | escape }}

@@ -132,7 +138,7 @@

- +

{% for error in form.published_date.errors %}

{{ error | escape }}

diff --git a/bookwyrm/tests/views/inbox/__init__.py b/bookwyrm/tests/views/inbox/__init__.py new file mode 100644 index 00000000..b6e690fd --- /dev/null +++ b/bookwyrm/tests/views/inbox/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/views/inbox/test_inbox.py b/bookwyrm/tests/views/inbox/test_inbox.py new file mode 100644 index 00000000..12d7a736 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox.py @@ -0,0 +1,108 @@ +""" tests incoming activities""" +import json +from unittest.mock import patch + +from django.http import HttpResponseNotAllowed, HttpResponseNotFound +from django.test import TestCase, Client + +from bookwyrm import models + + +# pylint: disable=too-many-public-methods +class Inbox(TestCase): + """ readthrough tests """ + + def setUp(self): + """ basic user and book data """ + self.client = Client() + local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + local_user.remote_id = "https://example.com/user/mouse" + local_user.save(broadcast=False) + self.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + def test_inbox_invalid_get(self): + """ shouldn't try to handle if the user is not found """ + result = self.client.get("/inbox", content_type="application/json") + self.assertIsInstance(result, HttpResponseNotAllowed) + + def test_inbox_invalid_user(self): + """ shouldn't try to handle if the user is not found """ + result = self.client.post( + "/user/bleh/inbox", + '{"type": "Test", "object": "exists"}', + content_type="application/json", + ) + self.assertIsInstance(result, HttpResponseNotFound) + + def test_inbox_invalid_bad_signature(self): + """ bad request for invalid signature """ + with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: + mock_valid.return_value = False + result = self.client.post( + "/user/mouse/inbox", + '{"type": "Announce", "object": "exists"}', + content_type="application/json", + ) + self.assertEqual(result.status_code, 401) + + def test_inbox_invalid_bad_signature_delete(self): + """ invalid signature for Delete is okay though """ + with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: + mock_valid.return_value = False + result = self.client.post( + "/user/mouse/inbox", + '{"type": "Delete", "object": "exists"}', + content_type="application/json", + ) + self.assertEqual(result.status_code, 200) + + def test_inbox_unknown_type(self): + """ never heard of that activity type, don't have a handler for it """ + with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: + result = self.client.post( + "/inbox", + '{"type": "Fish", "object": "exists"}', + content_type="application/json", + ) + mock_valid.return_value = True + self.assertIsInstance(result, HttpResponseNotFound) + + def test_inbox_success(self): + """ a known type, for which we start a task """ + activity = self.create_json + activity["object"] = { + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams", + } + with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: + mock_valid.return_value = True + + with patch("bookwyrm.views.inbox.activity_task.delay"): + result = self.client.post( + "/inbox", json.dumps(activity), content_type="application/json" + ) + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/tests/views/inbox/test_inbox_add.py b/bookwyrm/tests/views/inbox/test_inbox_add.py new file mode 100644 index 00000000..638d56d7 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_add.py @@ -0,0 +1,156 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase +import responses + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + + models.SiteSettings.objects.create() + + def test_handle_add_book_to_shelf(self): + """ shelving a book """ + work = models.Work.objects.create(title="work title") + book = models.Edition.objects.create( + title="Test", + remote_id="https://bookwyrm.social/book/37292", + parent_work=work, + ) + shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") + shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" + shelf.save() + + activity = { + "id": "https://bookwyrm.social/shelfbook/6189#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://bookwyrm.social/user/mouse/shelf/to-read", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + self.assertEqual(shelf.books.first(), book) + + @responses.activate + def test_handle_add_book_to_list(self): + """ listing a book """ + work = models.Work.objects.create(title="work title") + book = models.Edition.objects.create( + title="Test", + remote_id="https://bookwyrm.social/book/37292", + parent_work=work, + ) + + responses.add( + responses.GET, + "https://bookwyrm.social/user/mouse/list/to-read", + json={ + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams", + }, + ) + + activity = { + "id": "https://bookwyrm.social/listbook/6189#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://bookwyrm.social/user/mouse/list/to-read", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + + booklist = models.List.objects.get() + self.assertEqual(booklist.name, "Test List") + self.assertEqual(booklist.books.first(), book) + + @responses.activate + def test_handle_tag_book(self): + """ listing a book """ + work = models.Work.objects.create(title="work title") + book = models.Edition.objects.create( + title="Test", + remote_id="https://bookwyrm.social/book/37292", + parent_work=work, + ) + + responses.add( + responses.GET, + "https://www.example.com/tag/cool-tag", + json={ + "id": "https://1b1a78582461.ngrok.io/tag/tag", + "type": "OrderedCollection", + "totalItems": 0, + "first": "https://1b1a78582461.ngrok.io/tag/tag?page=1", + "last": "https://1b1a78582461.ngrok.io/tag/tag?page=1", + "name": "cool tag", + "@context": "https://www.w3.org/ns/activitystreams", + }, + ) + + activity = { + "id": "https://bookwyrm.social/listbook/6189#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://www.example.com/tag/cool-tag", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + + tag = models.Tag.objects.get() + self.assertFalse(models.List.objects.exists()) + self.assertEqual(tag.name, "cool tag") + self.assertEqual(tag.books.first(), book) diff --git a/bookwyrm/tests/views/inbox/test_inbox_announce.py b/bookwyrm/tests/views/inbox/test_inbox_announce.py new file mode 100644 index 00000000..a730045a --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_announce.py @@ -0,0 +1,190 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase +import responses + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + self.status = models.Status.objects.create( + user=self.local_user, + content="Test status", + remote_id="https://example.com/status/1", + ) + + self.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + @patch("bookwyrm.activitystreams.ActivityStream.add_status") + 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.remote_id, + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "published": "Mon, 25 May 2020 19:31:20 GMT", + } + with patch("bookwyrm.models.status.Status.ignore_activity") as discarder: + discarder.return_value = False + views.inbox.activity_task(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) + + @responses.activate + @patch("bookwyrm.activitystreams.ActivityStream.add_status") + def test_handle_boost_remote_status(self, redis_mock): + """ boost a status """ + work = models.Work.objects.create(title="work title") + book = models.Edition.objects.create( + title="Test", + remote_id="https://bookwyrm.social/book/37292", + parent_work=work, + ) + self.assertEqual(models.Notification.objects.count(), 0) + activity = { + "type": "Announce", + "id": "%s/boost" % self.status.remote_id, + "actor": self.remote_user.remote_id, + "object": "https://remote.com/status/1", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "published": "Mon, 25 May 2020 19:31:20 GMT", + } + responses.add( + responses.GET, + "https://remote.com/status/1", + json={ + "id": "https://remote.com/status/1", + "type": "Comment", + "published": "2021-04-05T18:04:59.735190+00:00", + "attributedTo": self.remote_user.remote_id, + "content": "

a comment

", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://b875df3d118b.ngrok.io/user/mouse/followers"], + "inReplyTo": "", + "inReplyToBook": book.remote_id, + "summary": "", + "tag": [], + "sensitive": False, + "@context": "https://www.w3.org/ns/activitystreams", + }, + ) + + with patch("bookwyrm.models.status.Status.ignore_activity") as discarder: + discarder.return_value = False + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + + boost = models.Boost.objects.get() + self.assertEqual(boost.boosted_status.remote_id, "https://remote.com/status/1") + self.assertEqual(boost.boosted_status.comment.status_type, "Comment") + self.assertEqual(boost.boosted_status.comment.book, book) + + @responses.activate + def test_handle_discarded_boost(self): + """ test a boost of a mastodon status that will be discarded """ + status = models.Status( + content="hi", + user=self.remote_user, + ) + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + status.save(broadcast=False) + activity = { + "type": "Announce", + "id": "http://www.faraway.com/boost/12", + "actor": self.remote_user.remote_id, + "object": status.remote_id, + } + responses.add( + responses.GET, status.remote_id, json=status.to_activity(), status=200 + ) + views.inbox.activity_task(activity) + self.assertEqual(models.Boost.objects.count(), 0) + + def test_handle_unboost(self): + """ undo a boost """ + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + boost = models.Boost.objects.create( + boosted_status=self.status, user=self.remote_user + ) + 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": boost.remote_id, + "actor": self.remote_user.remote_id, + "object": self.status.remote_id, + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "published": "Mon, 25 May 2020 19:31:20 GMT", + }, + } + with patch( + "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" + ) as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + self.assertFalse(models.Boost.objects.exists()) + + 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) diff --git a/bookwyrm/tests/views/inbox/test_inbox_block.py b/bookwyrm/tests/views/inbox/test_inbox_block.py new file mode 100644 index 00000000..e686c6b7 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_block.py @@ -0,0 +1,98 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxBlock(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + + models.SiteSettings.objects.create() + + def test_handle_blocks(self): + """ create a "block" database entry from an activity """ + self.local_user.followers.add(self.remote_user) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + models.UserFollowRequest.objects.create( + user_subject=self.local_user, user_object=self.remote_user + ) + self.assertTrue(models.UserFollows.objects.exists()) + self.assertTrue(models.UserFollowRequest.objects.exists()) + + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/9e1f41ac-9ddd-4159", + "type": "Block", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse", + } + + with patch( + "bookwyrm.activitystreams.ActivityStream.remove_user_statuses" + ) as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + views.inbox.activity_task(activity) + block = models.UserBlocks.objects.get() + self.assertEqual(block.user_subject, self.remote_user) + self.assertEqual(block.user_object, self.local_user) + self.assertEqual(block.remote_id, "https://example.com/9e1f41ac-9ddd-4159") + + self.assertFalse(models.UserFollows.objects.exists()) + self.assertFalse(models.UserFollowRequest.objects.exists()) + + def test_handle_unblock(self): + """ unblock a user """ + self.remote_user.blocks.add(self.local_user) + + block = models.UserBlocks.objects.get() + block.remote_id = "https://example.com/9e1f41ac-9ddd-4159" + block.save() + + self.assertEqual(block.user_subject, self.remote_user) + self.assertEqual(block.user_object, self.local_user) + activity = { + "type": "Undo", + "actor": "hi", + "id": "bleh", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/9e1f41ac-9ddd-4159", + "type": "Block", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse", + }, + } + with patch( + "bookwyrm.activitystreams.ActivityStream.add_user_statuses" + ) as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + self.assertFalse(models.UserBlocks.objects.exists()) diff --git a/bookwyrm/tests/views/inbox/test_inbox_create.py b/bookwyrm/tests/views/inbox/test_inbox_create.py new file mode 100644 index 00000000..437f6ffc --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_create.py @@ -0,0 +1,151 @@ +""" tests incoming activities""" +import json +import pathlib +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ readthrough tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + self.status = models.Status.objects.create( + user=self.local_user, + content="Test status", + remote_id="https://example.com/status/1", + ) + with patch("bookwyrm.models.user.set_remote_server.delay"): + 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.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + def test_handle_create_status(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 = self.create_json + activity["object"] = status_data + + with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + + 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 + views.inbox.activity_task(activity) + self.assertEqual(models.Status.objects.count(), 2) + + def test_handle_create_status_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 = self.create_json + activity["object"] = status_data + + with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + 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_status_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 = self.create_json + activity["object"] = status_data + + with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + 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_create_list(self): + """ a new list """ + activity = self.create_json + activity["object"] = { + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + book_list = models.List.objects.get() + self.assertEqual(book_list.name, "Test List") + self.assertEqual(book_list.curation, "curated") + self.assertEqual(book_list.description, "summary text") + self.assertEqual(book_list.remote_id, "https://example.com/list/22") diff --git a/bookwyrm/tests/views/inbox/test_inbox_delete.py b/bookwyrm/tests/views/inbox/test_inbox_delete.py new file mode 100644 index 00000000..65a75426 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_delete.py @@ -0,0 +1,106 @@ +""" tests incoming activities""" +from datetime import datetime +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + self.status = models.Status.objects.create( + user=self.remote_user, + content="Test status", + remote_id="https://example.com/status/1", + ) + + self.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + def test_handle_delete_status(self): + """ remove a status """ + self.assertFalse(self.status.deleted) + activity = { + "type": "Delete", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "id": "%s/activity" % self.status.remote_id, + "actor": self.remote_user.remote_id, + "object": {"id": self.status.remote_id, "type": "Tombstone"}, + } + with patch( + "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" + ) as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + # 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", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "id": "%s/activity" % self.status.remote_id, + "actor": self.remote_user.remote_id, + "object": {"id": self.status.remote_id, "type": "Tombstone"}, + } + with patch( + "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" + ) as redis_mock: + views.inbox.activity_task(activity) + self.assertTrue(redis_mock.called) + # 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) diff --git a/bookwyrm/tests/views/inbox/test_inbox_follow.py b/bookwyrm/tests/views/inbox/test_inbox_follow.py new file mode 100644 index 00000000..c549c31b --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_follow.py @@ -0,0 +1,205 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxRelationships(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + + models.SiteSettings.objects.create() + + 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": "https://example.com/user/mouse", + } + + self.assertFalse(models.UserFollowRequest.objects.exists()) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + views.inbox.activity_task(activity) + self.assertEqual(mock.call_count, 1) + + # 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 + self.assertFalse(models.UserFollowRequest.objects.exists()) + + # 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": "https://example.com/user/mouse", + } + + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.inbox.activity_task(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_undo_follow_request(self): + """ the requester cancels a follow request """ + self.local_user.manually_approves_followers = True + self.local_user.save(broadcast=False) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + request = models.UserFollowRequest.objects.create( + user_subject=self.remote_user, user_object=self.local_user + ) + self.assertTrue(self.local_user.follower_requests.exists()) + + activity = { + "type": "Undo", + "id": "bleh", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "actor": self.remote_user.remote_id, + "@context": "https://www.w3.org/ns/activitystreams", + "object": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": request.remote_id, + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse", + }, + } + + views.inbox.activity_task(activity) + + self.assertFalse(self.local_user.follower_requests.exists()) + + def test_handle_unfollow(self): + """ remove a relationship """ + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + rel = models.UserFollows.objects.create( + user_subject=self.remote_user, user_object=self.local_user + ) + activity = { + "type": "Undo", + "id": "bleh", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "actor": self.remote_user.remote_id, + "@context": "https://www.w3.org/ns/activitystreams", + "object": { + "id": rel.remote_id, + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse", + }, + } + self.assertEqual(self.remote_user, self.local_user.followers.first()) + + views.inbox.activity_task(activity) + self.assertIsNone(self.local_user.followers.first()) + + def test_handle_follow_accept(self): + """ a remote user approved a follow request from local """ + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, user_object=self.remote_user + ) + 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": rel.remote_id, + "type": "Follow", + "actor": "https://example.com/user/mouse", + "object": "https://example.com/users/rat", + }, + } + + self.assertEqual(models.UserFollowRequest.objects.count(), 1) + + views.inbox.activity_task(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_follow_reject(self): + """ turn down a follow request """ + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, user_object=self.remote_user + ) + 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": rel.remote_id, + "type": "Follow", + "actor": "https://example.com/user/mouse", + "object": "https://example.com/users/rat", + }, + } + + self.assertEqual(models.UserFollowRequest.objects.count(), 1) + + views.inbox.activity_task(activity) + + # request should be deleted + self.assertFalse(models.UserFollowRequest.objects.exists()) + self.assertFalse(self.remote_user.followers.exists()) diff --git a/bookwyrm/tests/views/inbox/test_inbox_like.py b/bookwyrm/tests/views/inbox/test_inbox_like.py new file mode 100644 index 00000000..05105b75 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_like.py @@ -0,0 +1,110 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + 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", + ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + self.status = models.Status.objects.create( + user=self.local_user, + content="Test status", + remote_id="https://example.com/status/1", + ) + + self.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + def test_handle_favorite(self): + """ fav a status """ + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/fav/1", + "actor": "https://example.com/users/rat", + "type": "Like", + "published": "Mon, 25 May 2020 19:31:20 GMT", + "object": self.status.remote_id, + } + + views.inbox.activity_task(activity) + + fav = models.Favorite.objects.get(remote_id="https://example.com/fav/1") + 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_ignore_favorite(self): + """ don't try to save an unknown status """ + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/fav/1", + "actor": "https://example.com/users/rat", + "type": "Like", + "published": "Mon, 25 May 2020 19:31:20 GMT", + "object": "https://unknown.status/not-found", + } + + views.inbox.activity_task(activity) + + self.assertFalse(models.Favorite.objects.exists()) + + def test_handle_unfavorite(self): + """ fav a status """ + activity = { + "id": "https://example.com/fav/1#undo", + "type": "Undo", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "actor": self.remote_user.remote_id, + "object": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/fav/1", + "actor": "https://example.com/users/rat", + "type": "Like", + "published": "Mon, 25 May 2020 19:31:20 GMT", + "object": self.status.remote_id, + }, + } + models.Favorite.objects.create( + status=self.status, + user=self.remote_user, + remote_id="https://example.com/fav/1", + ) + self.assertEqual(models.Favorite.objects.count(), 1) + + views.inbox.activity_task(activity) + self.assertEqual(models.Favorite.objects.count(), 0) diff --git a/bookwyrm/tests/views/inbox/test_inbox_remove.py b/bookwyrm/tests/views/inbox/test_inbox_remove.py new file mode 100644 index 00000000..b3e42bbd --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_remove.py @@ -0,0 +1,61 @@ +""" tests incoming activities""" +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxActivities(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + 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", + ) + + models.SiteSettings.objects.create() + + def test_handle_unshelve_book(self): + """ remove a book from a shelf """ + work = models.Work.objects.create(title="work title") + book = models.Edition.objects.create( + title="Test", + remote_id="https://bookwyrm.social/book/37292", + parent_work=work, + ) + shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") + shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" + shelf.save() + + shelfbook = models.ShelfBook.objects.create( + user=self.remote_user, shelf=shelf, book=book + ) + + self.assertEqual(shelf.books.first(), book) + self.assertEqual(shelf.books.count(), 1) + + activity = { + "id": shelfbook.remote_id, + "type": "Remove", + "actor": "https://example.com/users/rat", + "object": { + "type": "Edition", + "title": "Test Title", + "work": work.remote_id, + "id": "https://bookwyrm.social/book/37292", + }, + "target": "https://bookwyrm.social/user/mouse/shelf/to-read", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + self.assertFalse(shelf.books.exists()) diff --git a/bookwyrm/tests/views/inbox/test_inbox_update.py b/bookwyrm/tests/views/inbox/test_inbox_update.py new file mode 100644 index 00000000..012343e7 --- /dev/null +++ b/bookwyrm/tests/views/inbox/test_inbox_update.py @@ -0,0 +1,149 @@ +""" tests incoming activities""" +import json +import pathlib +from unittest.mock import patch + +from django.test import TestCase + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class InboxUpdate(TestCase): + """ inbox tests """ + + def setUp(self): + """ basic user and book data """ + self.local_user = models.User.objects.create_user( + "mouse@example.com", + "mouse@mouse.com", + "mouseword", + local=True, + localname="mouse", + ) + self.local_user.remote_id = "https://example.com/user/mouse" + self.local_user.save(broadcast=False) + + self.create_json = { + "id": "hi", + "type": "Create", + "actor": "hi", + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "object": {}, + } + models.SiteSettings.objects.create() + + def test_handle_update_list(self): + """ a new list """ + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + book_list = models.List.objects.create( + name="hi", remote_id="https://example.com/list/22", user=self.local_user + ) + activity = { + "type": "Update", + "to": [], + "cc": [], + "actor": "hi", + "id": "sdkjf", + "object": { + "id": "https://example.com/list/22", + "type": "BookList", + "totalItems": 1, + "first": "https://example.com/list/22?page=1", + "last": "https://example.com/list/22?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "summary": "summary text", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams", + }, + } + views.inbox.activity_task(activity) + book_list.refresh_from_db() + self.assertEqual(book_list.name, "Test List") + self.assertEqual(book_list.curation, "curated") + self.assertEqual(book_list.description, "summary text") + self.assertEqual(book_list.remote_id, "https://example.com/list/22") + + def test_handle_update_user(self): + """ update an existing user """ + # we only do this with remote users + self.local_user.local = False + self.local_user.save() + + datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_user.json") + userdata = json.loads(datafile.read_bytes()) + del userdata["icon"] + self.assertIsNone(self.local_user.name) + views.inbox.activity_task( + { + "type": "Update", + "to": [], + "cc": [], + "actor": "hi", + "id": "sdkjf", + "object": userdata, + } + ) + user = models.User.objects.get(id=self.local_user.id) + self.assertEqual(user.name, "MOUSE?? MOUSE!!") + self.assertEqual(user.username, "mouse@example.com") + self.assertEqual(user.localname, "mouse") + self.assertTrue(user.discoverable) + + def test_handle_update_edition(self): + """ update an existing edition """ + datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json") + bookdata = json.loads(datafile.read_bytes()) + + models.Work.objects.create( + title="Test Work", remote_id="https://bookwyrm.social/book/5988" + ) + 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"): + views.inbox.activity_task( + { + "type": "Update", + "to": [], + "cc": [], + "actor": "hi", + "id": "sdkjf", + "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/bw_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"): + views.inbox.activity_task( + { + "type": "Update", + "to": [], + "cc": [], + "actor": "hi", + "id": "sdkjf", + "object": bookdata, + } + ) + book = models.Work.objects.get(id=book.id) + self.assertEqual(book.title, "Piranesi") diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index c646b4b4..7d2bc42c 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -112,6 +112,9 @@ class ViewsHelpers(TestCase): result = views.helpers.handle_remote_webfinger("mouse@local.com") self.assertEqual(result, self.local_user) + result = views.helpers.handle_remote_webfinger("mOuSe@loCal.cOm") + self.assertEqual(result, self.local_user) + @responses.activate def test_load_user(self, _): """ find a remote user using webfinger """ diff --git a/bookwyrm/tests/views/test_inbox.py b/bookwyrm/tests/views/test_inbox.py deleted file mode 100644 index 44a29a92..00000000 --- a/bookwyrm/tests/views/test_inbox.py +++ /dev/null @@ -1,988 +0,0 @@ -""" tests incoming activities""" -from datetime import datetime -import json -import pathlib -from unittest.mock import patch - -from django.http import HttpResponseNotAllowed, HttpResponseNotFound -from django.test import TestCase, Client -import responses - -from bookwyrm import models, views - - -# pylint: disable=too-many-public-methods -class Inbox(TestCase): - """ readthrough tests """ - - def setUp(self): - """ basic user and book data """ - self.client = Client() - self.local_user = models.User.objects.create_user( - "mouse@example.com", - "mouse@mouse.com", - "mouseword", - local=True, - localname="mouse", - ) - self.local_user.remote_id = "https://example.com/user/mouse" - self.local_user.save(broadcast=False) - 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", - ) - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - with patch("bookwyrm.activitystreams.ActivityStream.add_status"): - self.status = models.Status.objects.create( - user=self.local_user, - content="Test status", - remote_id="https://example.com/status/1", - ) - - self.create_json = { - "id": "hi", - "type": "Create", - "actor": "hi", - "to": ["https://www.w3.org/ns/activitystreams#public"], - "cc": ["https://example.com/user/mouse/followers"], - "object": {}, - } - models.SiteSettings.objects.create() - - def test_inbox_invalid_get(self): - """ shouldn't try to handle if the user is not found """ - result = self.client.get("/inbox", content_type="application/json") - self.assertIsInstance(result, HttpResponseNotAllowed) - - def test_inbox_invalid_user(self): - """ shouldn't try to handle if the user is not found """ - result = self.client.post( - "/user/bleh/inbox", - '{"type": "Test", "object": "exists"}', - content_type="application/json", - ) - self.assertIsInstance(result, HttpResponseNotFound) - - def test_inbox_invalid_bad_signature(self): - """ bad request for invalid signature """ - with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: - mock_valid.return_value = False - result = self.client.post( - "/user/mouse/inbox", - '{"type": "Announce", "object": "exists"}', - content_type="application/json", - ) - self.assertEqual(result.status_code, 401) - - def test_inbox_invalid_bad_signature_delete(self): - """ invalid signature for Delete is okay though """ - with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: - mock_valid.return_value = False - result = self.client.post( - "/user/mouse/inbox", - '{"type": "Delete", "object": "exists"}', - content_type="application/json", - ) - self.assertEqual(result.status_code, 200) - - def test_inbox_unknown_type(self): - """ never heard of that activity type, don't have a handler for it """ - with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: - result = self.client.post( - "/inbox", - '{"type": "Fish", "object": "exists"}', - content_type="application/json", - ) - mock_valid.return_value = True - self.assertIsInstance(result, HttpResponseNotFound) - - def test_inbox_success(self): - """ a known type, for which we start a task """ - activity = self.create_json - activity["object"] = { - "id": "https://example.com/list/22", - "type": "BookList", - "totalItems": 1, - "first": "https://example.com/list/22?page=1", - "last": "https://example.com/list/22?page=1", - "name": "Test List", - "owner": "https://example.com/user/mouse", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "summary": "summary text", - "curation": "curated", - "@context": "https://www.w3.org/ns/activitystreams", - } - with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: - mock_valid.return_value = True - - with patch("bookwyrm.views.inbox.activity_task.delay"): - result = self.client.post( - "/inbox", json.dumps(activity), content_type="application/json" - ) - self.assertEqual(result.status_code, 200) - - def test_handle_create_status(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 = self.create_json - activity["object"] = status_data - - with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - - 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 - views.inbox.activity_task(activity) - self.assertEqual(models.Status.objects.count(), 2) - - def test_handle_create_status_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 = self.create_json - activity["object"] = status_data - - with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - 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_status_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 = self.create_json - activity["object"] = status_data - - with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - 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_create_list(self): - """ a new list """ - activity = self.create_json - activity["object"] = { - "id": "https://example.com/list/22", - "type": "BookList", - "totalItems": 1, - "first": "https://example.com/list/22?page=1", - "last": "https://example.com/list/22?page=1", - "name": "Test List", - "owner": "https://example.com/user/mouse", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "summary": "summary text", - "curation": "curated", - "@context": "https://www.w3.org/ns/activitystreams", - } - views.inbox.activity_task(activity) - book_list = models.List.objects.get() - self.assertEqual(book_list.name, "Test List") - self.assertEqual(book_list.curation, "curated") - self.assertEqual(book_list.description, "summary text") - self.assertEqual(book_list.remote_id, "https://example.com/list/22") - - 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": "https://example.com/user/mouse", - } - - self.assertFalse(models.UserFollowRequest.objects.exists()) - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: - views.inbox.activity_task(activity) - self.assertEqual(mock.call_count, 1) - - # 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 - self.assertFalse(models.UserFollowRequest.objects.exists()) - - # 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": "https://example.com/user/mouse", - } - - self.local_user.manually_approves_followers = True - self.local_user.save(broadcast=False) - - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - views.inbox.activity_task(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_undo_follow_request(self): - """ the requester cancels a follow request """ - self.local_user.manually_approves_followers = True - self.local_user.save(broadcast=False) - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - request = models.UserFollowRequest.objects.create( - user_subject=self.remote_user, user_object=self.local_user - ) - self.assertTrue(self.local_user.follower_requests.exists()) - - activity = { - "type": "Undo", - "id": "bleh", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "actor": self.remote_user.remote_id, - "@context": "https://www.w3.org/ns/activitystreams", - "object": { - "@context": "https://www.w3.org/ns/activitystreams", - "id": request.remote_id, - "type": "Follow", - "actor": "https://example.com/users/rat", - "object": "https://example.com/user/mouse", - }, - } - - views.inbox.activity_task(activity) - - self.assertFalse(self.local_user.follower_requests.exists()) - - def test_handle_unfollow(self): - """ remove a relationship """ - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - rel = models.UserFollows.objects.create( - user_subject=self.remote_user, user_object=self.local_user - ) - activity = { - "type": "Undo", - "id": "bleh", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "actor": self.remote_user.remote_id, - "@context": "https://www.w3.org/ns/activitystreams", - "object": { - "id": rel.remote_id, - "type": "Follow", - "actor": "https://example.com/users/rat", - "object": "https://example.com/user/mouse", - }, - } - self.assertEqual(self.remote_user, self.local_user.followers.first()) - - views.inbox.activity_task(activity) - self.assertIsNone(self.local_user.followers.first()) - - def test_handle_follow_accept(self): - """ a remote user approved a follow request from local """ - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - rel = models.UserFollowRequest.objects.create( - user_subject=self.local_user, user_object=self.remote_user - ) - 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": rel.remote_id, - "type": "Follow", - "actor": "https://example.com/user/mouse", - "object": "https://example.com/users/rat", - }, - } - - self.assertEqual(models.UserFollowRequest.objects.count(), 1) - - views.inbox.activity_task(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_follow_reject(self): - """ turn down a follow request """ - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - rel = models.UserFollowRequest.objects.create( - user_subject=self.local_user, user_object=self.remote_user - ) - 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": rel.remote_id, - "type": "Follow", - "actor": "https://example.com/user/mouse", - "object": "https://example.com/users/rat", - }, - } - - self.assertEqual(models.UserFollowRequest.objects.count(), 1) - - views.inbox.activity_task(activity) - - # request should be deleted - self.assertFalse(models.UserFollowRequest.objects.exists()) - self.assertFalse(self.remote_user.followers.exists()) - - def test_handle_update_list(self): - """ a new list """ - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - book_list = models.List.objects.create( - name="hi", remote_id="https://example.com/list/22", user=self.local_user - ) - activity = { - "type": "Update", - "to": [], - "cc": [], - "actor": "hi", - "id": "sdkjf", - "object": { - "id": "https://example.com/list/22", - "type": "BookList", - "totalItems": 1, - "first": "https://example.com/list/22?page=1", - "last": "https://example.com/list/22?page=1", - "name": "Test List", - "owner": "https://example.com/user/mouse", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "summary": "summary text", - "curation": "curated", - "@context": "https://www.w3.org/ns/activitystreams", - }, - } - views.inbox.activity_task(activity) - book_list.refresh_from_db() - self.assertEqual(book_list.name, "Test List") - self.assertEqual(book_list.curation, "curated") - self.assertEqual(book_list.description, "summary text") - self.assertEqual(book_list.remote_id, "https://example.com/list/22") - - def test_handle_delete_status(self): - """ remove a status """ - self.status.user = self.remote_user - self.status.save(broadcast=False) - - self.assertFalse(self.status.deleted) - activity = { - "type": "Delete", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "id": "%s/activity" % self.status.remote_id, - "actor": self.remote_user.remote_id, - "object": {"id": self.status.remote_id, "type": "Tombstone"}, - } - with patch( - "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" - ) as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - # 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 """ - self.status.user = self.remote_user - self.status.save(broadcast=False) - 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", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "id": "%s/activity" % self.status.remote_id, - "actor": self.remote_user.remote_id, - "object": {"id": self.status.remote_id, "type": "Tombstone"}, - } - with patch( - "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" - ) as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - # 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 = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/fav/1", - "actor": "https://example.com/users/rat", - "type": "Like", - "published": "Mon, 25 May 2020 19:31:20 GMT", - "object": self.status.remote_id, - } - - views.inbox.activity_task(activity) - - fav = models.Favorite.objects.get(remote_id="https://example.com/fav/1") - 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_ignore_favorite(self): - """ don't try to save an unknown status """ - activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/fav/1", - "actor": "https://example.com/users/rat", - "type": "Like", - "published": "Mon, 25 May 2020 19:31:20 GMT", - "object": "https://unknown.status/not-found", - } - - views.inbox.activity_task(activity) - - self.assertFalse(models.Favorite.objects.exists()) - - def test_handle_unfavorite(self): - """ fav a status """ - activity = { - "id": "https://example.com/fav/1#undo", - "type": "Undo", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "actor": self.remote_user.remote_id, - "object": { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/fav/1", - "actor": "https://example.com/users/rat", - "type": "Like", - "published": "Mon, 25 May 2020 19:31:20 GMT", - "object": self.status.remote_id, - }, - } - models.Favorite.objects.create( - status=self.status, - user=self.remote_user, - remote_id="https://example.com/fav/1", - ) - self.assertEqual(models.Favorite.objects.count(), 1) - - views.inbox.activity_task(activity) - self.assertEqual(models.Favorite.objects.count(), 0) - - @patch("bookwyrm.activitystreams.ActivityStream.add_status") - 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.remote_id, - "to": ["https://www.w3.org/ns/activitystreams#public"], - "cc": ["https://example.com/user/mouse/followers"], - "published": "Mon, 25 May 2020 19:31:20 GMT", - } - with patch("bookwyrm.models.status.Status.ignore_activity") as discarder: - discarder.return_value = False - views.inbox.activity_task(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) - - @responses.activate - @patch("bookwyrm.activitystreams.ActivityStream.add_status") - def test_handle_boost_remote_status(self, redis_mock): - """ boost a status """ - work = models.Work.objects.create(title="work title") - book = models.Edition.objects.create( - title="Test", - remote_id="https://bookwyrm.social/book/37292", - parent_work=work, - ) - self.assertEqual(models.Notification.objects.count(), 0) - activity = { - "type": "Announce", - "id": "%s/boost" % self.status.remote_id, - "actor": self.remote_user.remote_id, - "object": "https://remote.com/status/1", - "to": ["https://www.w3.org/ns/activitystreams#public"], - "cc": ["https://example.com/user/mouse/followers"], - "published": "Mon, 25 May 2020 19:31:20 GMT", - } - responses.add( - responses.GET, - "https://remote.com/status/1", - json={ - "id": "https://remote.com/status/1", - "type": "Comment", - "published": "2021-04-05T18:04:59.735190+00:00", - "attributedTo": self.remote_user.remote_id, - "content": "

a comment

", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://b875df3d118b.ngrok.io/user/mouse/followers"], - "inReplyTo": "", - "inReplyToBook": book.remote_id, - "summary": "", - "tag": [], - "sensitive": False, - "@context": "https://www.w3.org/ns/activitystreams", - }, - ) - - with patch("bookwyrm.models.status.Status.ignore_activity") as discarder: - discarder.return_value = False - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - - boost = models.Boost.objects.get() - self.assertEqual(boost.boosted_status.remote_id, "https://remote.com/status/1") - self.assertEqual(boost.boosted_status.comment.status_type, "Comment") - self.assertEqual(boost.boosted_status.comment.book, book) - - @responses.activate - def test_handle_discarded_boost(self): - """ test a boost of a mastodon status that will be discarded """ - status = models.Status( - content="hi", - user=self.remote_user, - ) - with patch("bookwyrm.activitystreams.ActivityStream.add_status"): - status.save(broadcast=False) - activity = { - "type": "Announce", - "id": "http://www.faraway.com/boost/12", - "actor": self.remote_user.remote_id, - "object": status.remote_id, - } - responses.add( - responses.GET, status.remote_id, json=status.to_activity(), status=200 - ) - views.inbox.activity_task(activity) - self.assertEqual(models.Boost.objects.count(), 0) - - def test_handle_unboost(self): - """ undo a boost """ - with patch("bookwyrm.activitystreams.ActivityStream.add_status"): - boost = models.Boost.objects.create( - boosted_status=self.status, user=self.remote_user - ) - 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": boost.remote_id, - "actor": self.remote_user.remote_id, - "object": self.status.remote_id, - "to": ["https://www.w3.org/ns/activitystreams#public"], - "cc": ["https://example.com/user/mouse/followers"], - "published": "Mon, 25 May 2020 19:31:20 GMT", - }, - } - with patch( - "bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores" - ) as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - self.assertFalse(models.Boost.objects.exists()) - - 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): - """ shelving a book """ - work = models.Work.objects.create(title="work title") - book = models.Edition.objects.create( - title="Test", - remote_id="https://bookwyrm.social/book/37292", - parent_work=work, - ) - shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") - shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" - shelf.save() - - activity = { - "id": "https://bookwyrm.social/shelfbook/6189#add", - "type": "Add", - "actor": "https://example.com/users/rat", - "object": { - "type": "Edition", - "title": "Test Title", - "work": work.remote_id, - "id": "https://bookwyrm.social/book/37292", - }, - "target": "https://bookwyrm.social/user/mouse/shelf/to-read", - "@context": "https://www.w3.org/ns/activitystreams", - } - views.inbox.activity_task(activity) - self.assertEqual(shelf.books.first(), book) - - def test_handle_unshelve_book(self): - """ remove a book from a shelf """ - work = models.Work.objects.create(title="work title") - book = models.Edition.objects.create( - title="Test", - remote_id="https://bookwyrm.social/book/37292", - parent_work=work, - ) - shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") - shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" - shelf.save() - - shelfbook = models.ShelfBook.objects.create( - user=self.remote_user, shelf=shelf, book=book - ) - - self.assertEqual(shelf.books.first(), book) - self.assertEqual(shelf.books.count(), 1) - - activity = { - "id": shelfbook.remote_id, - "type": "Remove", - "actor": "https://example.com/users/rat", - "object": { - "type": "Edition", - "title": "Test Title", - "work": work.remote_id, - "id": "https://bookwyrm.social/book/37292", - }, - "target": "https://bookwyrm.social/user/mouse/shelf/to-read", - "@context": "https://www.w3.org/ns/activitystreams", - } - views.inbox.activity_task(activity) - self.assertFalse(shelf.books.exists()) - - @responses.activate - def test_handle_add_book_to_list(self): - """ listing a book """ - work = models.Work.objects.create(title="work title") - book = models.Edition.objects.create( - title="Test", - remote_id="https://bookwyrm.social/book/37292", - parent_work=work, - ) - - responses.add( - responses.GET, - "https://bookwyrm.social/user/mouse/list/to-read", - json={ - "id": "https://example.com/list/22", - "type": "BookList", - "totalItems": 1, - "first": "https://example.com/list/22?page=1", - "last": "https://example.com/list/22?page=1", - "name": "Test List", - "owner": "https://example.com/user/mouse", - "to": ["https://www.w3.org/ns/activitystreams#Public"], - "cc": ["https://example.com/user/mouse/followers"], - "summary": "summary text", - "curation": "curated", - "@context": "https://www.w3.org/ns/activitystreams", - }, - ) - - activity = { - "id": "https://bookwyrm.social/listbook/6189#add", - "type": "Add", - "actor": "https://example.com/users/rat", - "object": { - "type": "Edition", - "title": "Test Title", - "work": work.remote_id, - "id": "https://bookwyrm.social/book/37292", - }, - "target": "https://bookwyrm.social/user/mouse/list/to-read", - "@context": "https://www.w3.org/ns/activitystreams", - } - views.inbox.activity_task(activity) - - booklist = models.List.objects.get() - self.assertEqual(booklist.name, "Test List") - self.assertEqual(booklist.books.first(), book) - - @responses.activate - def test_handle_tag_book(self): - """ listing a book """ - work = models.Work.objects.create(title="work title") - book = models.Edition.objects.create( - title="Test", - remote_id="https://bookwyrm.social/book/37292", - parent_work=work, - ) - - responses.add( - responses.GET, - "https://www.example.com/tag/cool-tag", - json={ - "id": "https://1b1a78582461.ngrok.io/tag/tag", - "type": "OrderedCollection", - "totalItems": 0, - "first": "https://1b1a78582461.ngrok.io/tag/tag?page=1", - "last": "https://1b1a78582461.ngrok.io/tag/tag?page=1", - "name": "cool tag", - "@context": "https://www.w3.org/ns/activitystreams", - }, - ) - - activity = { - "id": "https://bookwyrm.social/listbook/6189#add", - "type": "Add", - "actor": "https://example.com/users/rat", - "object": { - "type": "Edition", - "title": "Test Title", - "work": work.remote_id, - "id": "https://bookwyrm.social/book/37292", - }, - "target": "https://www.example.com/tag/cool-tag", - "@context": "https://www.w3.org/ns/activitystreams", - } - views.inbox.activity_task(activity) - - tag = models.Tag.objects.get() - self.assertFalse(models.List.objects.exists()) - self.assertEqual(tag.name, "cool tag") - self.assertEqual(tag.books.first(), book) - - def test_handle_update_user(self): - """ update an existing user """ - # we only do this with remote users - self.local_user.local = False - self.local_user.save() - - datafile = pathlib.Path(__file__).parent.joinpath("../data/ap_user.json") - userdata = json.loads(datafile.read_bytes()) - del userdata["icon"] - self.assertIsNone(self.local_user.name) - views.inbox.activity_task( - { - "type": "Update", - "to": [], - "cc": [], - "actor": "hi", - "id": "sdkjf", - "object": userdata, - } - ) - user = models.User.objects.get(id=self.local_user.id) - self.assertEqual(user.name, "MOUSE?? MOUSE!!") - self.assertEqual(user.username, "mouse@example.com") - self.assertEqual(user.localname, "mouse") - self.assertTrue(user.discoverable) - - def test_handle_update_edition(self): - """ update an existing edition """ - datafile = pathlib.Path(__file__).parent.joinpath("../data/bw_edition.json") - bookdata = json.loads(datafile.read_bytes()) - - models.Work.objects.create( - title="Test Work", remote_id="https://bookwyrm.social/book/5988" - ) - 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"): - views.inbox.activity_task( - { - "type": "Update", - "to": [], - "cc": [], - "actor": "hi", - "id": "sdkjf", - "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/bw_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"): - views.inbox.activity_task( - { - "type": "Update", - "to": [], - "cc": [], - "actor": "hi", - "id": "sdkjf", - "object": bookdata, - } - ) - book = models.Work.objects.get(id=book.id) - self.assertEqual(book.title, "Piranesi") - - def test_handle_blocks(self): - """ create a "block" database entry from an activity """ - self.local_user.followers.add(self.remote_user) - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - models.UserFollowRequest.objects.create( - user_subject=self.local_user, user_object=self.remote_user - ) - self.assertTrue(models.UserFollows.objects.exists()) - self.assertTrue(models.UserFollowRequest.objects.exists()) - - activity = { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/9e1f41ac-9ddd-4159", - "type": "Block", - "actor": "https://example.com/users/rat", - "object": "https://example.com/user/mouse", - } - - with patch( - "bookwyrm.activitystreams.ActivityStream.remove_user_statuses" - ) as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - views.inbox.activity_task(activity) - block = models.UserBlocks.objects.get() - self.assertEqual(block.user_subject, self.remote_user) - self.assertEqual(block.user_object, self.local_user) - self.assertEqual(block.remote_id, "https://example.com/9e1f41ac-9ddd-4159") - - self.assertFalse(models.UserFollows.objects.exists()) - self.assertFalse(models.UserFollowRequest.objects.exists()) - - def test_handle_unblock(self): - """ unblock a user """ - self.remote_user.blocks.add(self.local_user) - - block = models.UserBlocks.objects.get() - block.remote_id = "https://example.com/9e1f41ac-9ddd-4159" - block.save() - - self.assertEqual(block.user_subject, self.remote_user) - self.assertEqual(block.user_object, self.local_user) - activity = { - "type": "Undo", - "actor": "hi", - "id": "bleh", - "to": ["https://www.w3.org/ns/activitystreams#public"], - "cc": ["https://example.com/user/mouse/followers"], - "object": { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://example.com/9e1f41ac-9ddd-4159", - "type": "Block", - "actor": "https://example.com/users/rat", - "object": "https://example.com/user/mouse", - }, - } - with patch( - "bookwyrm.activitystreams.ActivityStream.add_user_statuses" - ) as redis_mock: - views.inbox.activity_task(activity) - self.assertTrue(redis_mock.called) - self.assertFalse(models.UserBlocks.objects.exists()) diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 58886cad..731fc24c 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -1,6 +1,7 @@ """ the good stuff! the books! """ from uuid import uuid4 +from dateutil.parser import parse as dateparse from django.contrib.auth.decorators import login_required, permission_required from django.contrib.postgres.search import SearchRank, SearchVector from django.core.files.base import ContentFile @@ -10,6 +11,7 @@ from django.db.models import Avg, Q from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse +from django.utils.datastructures import MultiValueDictKeyError from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.http import require_POST @@ -172,6 +174,20 @@ class EditBook(View): data["confirm_mode"] = True # this isn't preserved because it isn't part of the form obj data["remove_authors"] = request.POST.getlist("remove_authors") + # make sure the dates are passed in as datetime, they're currently a string + # QueryDicts are immutable, we need to copy + formcopy = data["form"].data.copy() + try: + formcopy["first_published_date"] = dateparse( + formcopy["first_published_date"] + ) + except (MultiValueDictKeyError, ValueError): + pass + try: + formcopy["published_date"] = dateparse(formcopy["published_date"]) + except (MultiValueDictKeyError, ValueError): + pass + data["form"].data = formcopy return TemplateResponse(request, "book/edit_book.html", data) remove_authors = request.POST.getlist("remove_authors") diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 75c5da8f..2b6501ff 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -124,7 +124,7 @@ def handle_remote_webfinger(query): return None try: - user = models.User.objects.get(username=query) + user = models.User.objects.get(username__iexact=query) except models.User.DoesNotExist: url = "https://%s/.well-known/webfinger?resource=acct:%s" % (domain, query) try: @@ -138,7 +138,7 @@ def handle_remote_webfinger(query): user = activitypub.resolve_remote_id( link["href"], model=models.User ) - except KeyError: + except (KeyError, activitypub.ActivitySerializerError): return None return user diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py index 28f393c8..9e7df9f4 100644 --- a/bookwyrm/views/search.py +++ b/bookwyrm/views/search.py @@ -34,7 +34,7 @@ class Search(View): if query and re.match(regex.full_username, query): handle_remote_webfinger(query) - # do a user search + # do a user search user_results = ( models.User.viewer_aware_objects(request.user) .annotate( diff --git a/bw-dev b/bw-dev index b9c4b2a1..42fb4a2e 100755 --- a/bw-dev +++ b/bw-dev @@ -19,7 +19,6 @@ function clean { function runweb { docker-compose run --rm web "$@" - clean } function execdb { @@ -64,17 +63,16 @@ case "$CMD" in clean ;; makemigrations) - execweb python manage.py makemigrations "$@" + runweb python manage.py makemigrations "$@" ;; migrate) - execweb python manage.py rename_app fedireads bookwyrm - execweb python manage.py migrate "$@" + runweb python manage.py migrate "$@" ;; bash) - execweb bash + runweb bash ;; shell) - execweb python manage.py shell + runweb python manage.py shell ;; dbshell) execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB} @@ -83,22 +81,19 @@ case "$CMD" in docker-compose restart celery_worker ;; test) - execweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" + runweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" ;; pytest) - execweb pytest --no-cov-on-fail "$@" - ;; - test_report) - execweb coverage report + runweb pytest --no-cov-on-fail "$@" ;; collectstatic) - execweb python manage.py collectstatic --no-input + runweb python manage.py collectstatic --no-input ;; makemessages) - execweb django-admin makemessages --no-wrap --ignore=venv3 $@ + runweb django-admin makemessages --no-wrap --ignore=venv3 $@ ;; compilemessages) - execweb django-admin compilemessages --ignore venv3 $@ + runweb django-admin compilemessages --ignore venv3 $@ ;; build) docker-compose build @@ -110,7 +105,7 @@ case "$CMD" in makeitblack ;; populate_streams) - execweb python manage.py populate_streams + runweb python manage.py populate_streams ;; *) echo "Unrecognised command. Try: build, clean, up, initdb, resetdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, test, pytest, test_report, black, populate_feeds" diff --git a/celerywyrm/settings.py b/celerywyrm/settings.py index 7591163b..7947cb1c 100644 --- a/celerywyrm/settings.py +++ b/celerywyrm/settings.py @@ -20,7 +20,7 @@ EMAIL_HOST = env("EMAIL_HOST") EMAIL_PORT = env("EMAIL_PORT") EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = env("EMAIL_USE_TLS") +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS") # Build paths inside the project like this: os.path.join(BASE_DIR, ...) diff --git a/requirements.txt b/requirements.txt index 16561da5..6b7d82d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ celery==4.4.2 -Django==3.1.6 +Django==3.1.8 django-model-utils==4.0.0 environs==7.2.0 flower==0.9.4