diff --git a/federation/entities/activitypub/mappers.py b/federation/entities/activitypub/mappers.py index ff83aab..9b7cdc4 100644 --- a/federation/entities/activitypub/mappers.py +++ b/federation/entities/activitypub/mappers.py @@ -43,6 +43,7 @@ def get_outbound_entity(entity: BaseEntity, private_key): elif entity.entity_type == 'Share': outbound = models.Announce.from_base(entity) outbound.activity = models.Undo + outbound._required.remove('id') elif entity.entity_type == 'Profile': outbound = models.Delete.from_base(entity) elif cls == Share: diff --git a/federation/entities/activitypub/models.py b/federation/entities/activitypub/models.py index 1d4cd94..7c3aa46 100644 --- a/federation/entities/activitypub/models.py +++ b/federation/entities/activitypub/models.py @@ -5,6 +5,7 @@ import logging from typing import List, Callable, Dict, Union, Optional import uuid +import bleach from calamus import fields from calamus.schema import JsonLDAnnotation, JsonLDSchema, JsonLDSchemaOpts from calamus.utils import normalize_value @@ -15,8 +16,9 @@ from pyld import jsonld import requests_cache as rc from federation.entities.activitypub.constants import CONTEXT, NAMESPACE_PUBLIC -from federation.entities.mixins import BaseEntity +from federation.entities.mixins import BaseEntity, RawContentMixin from federation.entities.utils import get_base_attributes +from federation.outbound import handle_send from federation.types import UserType, ReceiverVariant from federation.utils.activitypub import retrieve_and_parse_document, retrieve_and_parse_profile from federation.utils.text import with_slash, validate_handle @@ -199,15 +201,20 @@ class MixedField(fields.Nested): isinstance(value, list) and len(value) > 0 and isinstance(value[0], str)): return self.iri._serialize(value, attr, obj, **kwargs) else: - value = value[0] if isinstance(value, list) and len(value) > 0 else value + value = value[0] if isinstance(value, list) and len(value) == 1 else value if isinstance(value, list) and len(value) == 0: value = None return super()._serialize(value, attr, obj, **kwargs) def _deserialize(self, value, attr, data, **kwargs): + print(attr, value, type(value)) # this is just so the ACTIVITYPUB_POST_OBJECT_IMAGES test payload passes if len(value) == 0: return value - if isinstance(value, list) and value[0] == {}: return {} + if isinstance(value, list): + if value[0] == {}: return {} + else: + value = [value] + ret = [] for item in value: @@ -258,13 +265,13 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation): also_known_as = IRI(as2.alsoKnownAs) icon = MixedField(as2.icon, nested='ImageSchema') image = MixedField(as2.image, nested='ImageSchema') - tag_list = MixedField(as2.tag, nested=['HashtagSchema','MentionSchema','PropertyValueSchema','EmojiSchema']) + tag_objects = MixedField(as2.tag, nested=['HashtagSchema','MentionSchema','PropertyValueSchema','EmojiSchema'], many=True) attachment = fields.Nested(as2.attachment, nested=['ImageSchema', 'AudioSchema', 'DocumentSchema','PropertyValueSchema','IdentityProofSchema'], many=True) content_map = LanguageMap(as2.content) # language maps are not implemented in calamus context = IRI(as2.context) guid = fields.String(diaspora.guid) name = fields.String(as2.name) - generator = MixedField(as2.generator, nested='ServiceSchema') + generator = MixedField(as2.generator, nested=['ApplicationSchema','ServiceSchema']) created_at = fields.DateTime(as2.published, add_value_types=True) replies = MixedField(as2.replies, nested=['CollectionSchema','OrderedCollectionSchema']) signature = MixedField(sec.signature, nested = 'SignatureSchema') @@ -273,7 +280,6 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation): to = IRI(as2.to) cc = IRI(as2.cc) media_type = fields.String(as2.mediaType) - sensitive = fields.Boolean(as2.sensitive) source = CompactedDict(as2.source) # The following properties are defined by some platforms, but are not implemented yet @@ -287,6 +293,7 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation): def to_as2(self): obj = self.activity if isinstance(self.activity, Activity) else self + print('to_as2', obj, getattr(obj, 'tag_objects', None)) return jsonld.compact(obj.dump(), CONTEXT) @classmethod @@ -379,7 +386,7 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation): @post_dump def sanitize(self, data, **kwargs): - return {k: v for k,v in data.items() if v} + return {k: v for k,v in data.items() if v or isinstance(v, bool)} class Home(metaclass=JsonLDAnnotation): country_name = fields.String(fields.IRIReference("http://www.w3.org/2006/vcard/ns#","country-name")) @@ -392,8 +399,11 @@ class Home(metaclass=JsonLDAnnotation): class NormalizedList(fields.List): def _deserialize(self,value, attr, data, **kwargs): + print('List', attr, value) value = normalize_value(value) - return super()._deserialize(value,attr,data,**kwargs) + ret = super()._deserialize(value,attr,data,**kwargs) + print('List after', ret) + return ret class Collection(Object): @@ -435,7 +445,7 @@ class OrderedCollectionPage(OrderedCollection, CollectionPage): # AP defines [Ii]mage and [Aa]udio objects/properties, but only a Video object # seen with Peertube payloads only so far class Document(Object): - inline = fields.Boolean(pyfed.inlineImage) + inline = fields.Boolean(pyfed.inlineImage, default=False) height = Integer(as2.height, flavor=xsd.nonNegativeInteger, add_value_types=True) width = Integer(as2.width, flavor=xsd.nonNegativeInteger, add_value_types=True) blurhash = fields.String(toot.blurhash) @@ -490,9 +500,18 @@ class Link(metaclass=JsonLDAnnotation): # Not implemented yet #preview : variable type? + def __init__(self, *args, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + class Meta: rdf_type = as2.Link + @post_dump + def noid(self, data, **kwargs): + if data['@id'].startswith('_:'): data.pop('@id') + return data + @post_load def make_instance(self, data, **kwargs): data.pop('@id', None) @@ -536,9 +555,9 @@ class Emoji(Object): class Person(Object, base.Profile): id = fields.Id() inbox = IRI(ldp.inbox) - outbox = IRI(as2.outbox, dump_derived={'fmt': '{id}outbox/', 'fields': ['id']}) - following = IRI(as2.following, dump_derived={'fmt': '{id}following/', 'fields': ['id']}) - followers = IRI(as2.followers, dump_derived={'fmt': '{id}followers/', 'fields': ['id']}) + outbox = IRI(as2.outbox, dump_derived={'fmt': '{id}/outbox/', 'fields': ['id']}) + following = IRI(as2.following, dump_derived={'fmt': '{id}/following/', 'fields': ['id']}) + followers = IRI(as2.followers, dump_derived={'fmt': '{id}/followers/', 'fields': ['id']}) username = fields.String(as2.preferredUsername) endpoints = CompactedDict(as2.endpoints) shared_inbox = IRI(as2.sharedInbox) # misskey adds this @@ -578,6 +597,10 @@ class Person(Object, base.Profile): super().__init__(*args, **kwargs) self._allowed_children += (PropertyValue, IdentityProof) + def to_as2(self): + self.id = self.id.rstrip('/') # TODO: sort out the trailing / business + return super().to_as2() + @property def inboxes(self): if self._inboxes: return self._inboxes @@ -611,7 +634,8 @@ class Person(Object, base.Profile): @public_key.setter def public_key(self, value): self._public_key = value - self.public_key_dict = {'id': self.id+'#main-key', 'owner': self.id, 'publicKeyPem': value} + id_ = self.id.rstrip('/') + self.public_key_dict = {'id': id_+'#main-key', 'owner': id_, 'publicKeyPem': value} @property def image_urls(self): @@ -667,28 +691,38 @@ class Service(Person): rdf_type = as2.Service +class Application(Person): + class Meta: + rdf_type = as2.Application + + # The to_base method is used to handle cases where an AP object type matches multiple # classes depending on the existence/value of specific propertie(s) or # when the same class is used both as an object or an activity or # when a property can't be directly deserialized from the payload. # calamus Nested field can't handle using the same model # or the same type in multiple schemas -class Note(Object): +class Note(Object, RawContentMixin): id = fields.Id() actor_id = IRI(as2.attributedTo) target_id = IRI(as2.inReplyTo) conversation = fields.RawJsonLD(ostatus.conversation) in_reply_to_atom_uri = IRI(ostatus.inReplyToAtomUri) + sensitive = fields.Boolean(as2.sensitive, default=False) summary = fields.String(as2.summary) url = IRI(as2.url) _raw_content = None __children = [] def __init__(self, *args, **kwargs): + self.tag_objects = [] # mutable objects... super().__init__(*args, **kwargs) + self.extract_mentions() self._allowed_children += (base.Image, base.Audio, base.Video) def to_as2(self): + self.sensitive = 'nsfw' in self.tags + if self.activity_id: activity = Create if hasattr(self, 'times'): @@ -727,6 +761,9 @@ class Note(Object): ) for image in self.embedded_images ] self.extract_mentions() + self.content_map = {'orig': self.rendered_content} + self.add_object_mentions() + self.add_object_tags() def post_receive(self) -> None: """ @@ -752,11 +789,10 @@ class Note(Object): skip_tags=["code", "pre"], ) - def add_object_tags(self) -> List[Dict]: + def add_object_tags(self) -> None: """ Populate tags to the object.tag list. """ - tags = [] try: config = get_configuration() except ImportError: @@ -767,14 +803,24 @@ class Note(Object): else: tags_path = None for tag in self.tags: - _tag = { - 'type': 'Hashtag', - 'name': f'#{tag}', - } + _tag = Hashtag(name=f'#{tag}') if tags_path: - _tag["href"] = tags_path.replace(":tag:", tag) - tags.append(_tag) - return tags + _tag.href = tags_path.replace(":tag:", tag) + self.tag_objects.append(_tag) + + def add_object_mentions(self) -> None: + """ + Populate mentions to the object.tag list. + """ + if len(self._mentions): + mentions = list(self._mentions) + mentions.sort() + for mention in mentions: + if mention.startswith("http"): + self.tag_objects.append(Mention(href=mention, name=mention)) + elif validate_handle(mention): + # Look up via WebFinger + self.tag_objects.append(Mention(href=mention, name=mention)) # TODO need to implement fetch via webfinger for AP handles first def extract_mentions(self): """ @@ -782,10 +828,9 @@ class Note(Object): """ super().extract_mentions() - if getattr(self, 'tag_list', None): - from federation.entities.activitypub.models import Mention # Circulars - tag_list = self.tag_list if isinstance(self.tag_list, list) else [self.tag_list] - for tag in tag_list: + if getattr(self, 'tag_objects', None): + tag_objects = self.tag_objects if isinstance(self.tag_objects, list) else [self.tag_objects] + for tag in tag_objects: if isinstance(tag, Mention): self._mentions.add(tag.href) @@ -817,7 +862,6 @@ class Note(Object): self._raw_content = value if self._media_type == 'text/markdown': self.source = {'content': value, 'mediaType': self._media_type} - self.content_map = {'orig': self.rendered_content} @property def _children(self): @@ -1011,20 +1055,22 @@ class Follow(Activity, base.Follow): class Meta: rdf_type = as2.Follow + exclude = ('created_at',) class Announce(Activity, base.Share): - id = fields.Id(default="unused") # needed for validation with undo + id = fields.Id() target_id = IRI(as2.object) def to_as2(self): if isinstance(self.activity, type): self.activity = self.activity( - activity_id = f"{self.actor_id}#share-{uuid.uuid4()}", + activity_id = self.activity_id if self.activity_id else f"{self.actor_id}#share-{uuid.uuid4()}", actor_id = self.actor_id, created_at = self.created_at, - object_ = self.target_id + object_ = self ) + self.id = f"{self.target_id}" return super().to_as2() @@ -1051,7 +1097,7 @@ class Tombstone(Object, base.Retraction): def to_as2(self): if not isinstance(self.activity, type): return None self.activity = self.activity( - activity_id = f"{self.actor_id}#delete-{uuid.uuid4()}", + activity_id = self.activity_id, actor_id = self.actor_id, created_at = self.created_at, object_ = self, @@ -1096,6 +1142,7 @@ class Accept(Create, base.Accept): class Meta: rdf_type = as2.Accept + exclude = ('created_at',) class Delete(Create, base.Retraction): @@ -1117,6 +1164,7 @@ class Update(Create): class Undo(Create): class Meta: rdf_type = as2.Undo + exclude = ('created_at',) class View(Create): @@ -1241,7 +1289,7 @@ def model_to_objects(payload): try: entity = model.schema().load(payload) except (KeyError, jsonld.JsonLdError, exceptions.ValidationError) as exc : # Just give up for now. This must be made robust - logger.error(f"Error parsing jsonld payload ({exc})") + logger.error(f"Error parsing jsonld payload ({exc})") return None if isinstance(getattr(entity, 'object_', None), Object): diff --git a/federation/outbound.py b/federation/outbound.py index fc44732..613d3fb 100644 --- a/federation/outbound.py +++ b/federation/outbound.py @@ -2,6 +2,7 @@ import copy import importlib import json import logging +from pprint import pprint import traceback from typing import List, Dict, Union @@ -357,8 +358,11 @@ def handle_send( # Do actual sending for payload in payloads: for url in payload["urls"]: - print(url, payload["payload"]) - continue + #try: + # pprint(json.loads(payload["payload"])) + #except: + # pass + #continue try: # TODO send_document and fetch_document need to handle rate limits send_document( diff --git a/federation/tests/entities/activitypub/test_entities.py b/federation/tests/entities/activitypub/test_entities.py index 2e37606..546c7ca 100644 --- a/federation/tests/entities/activitypub/test_entities.py +++ b/federation/tests/entities/activitypub/test_entities.py @@ -4,9 +4,8 @@ from pprint import pprint # noinspection PyPackageRequirements from Crypto.PublicKey.RSA import RsaKey -from federation.entities.activitypub.constants import ( - CONTEXTS_DEFAULT, CONTEXT_MANUALLY_APPROVES_FOLLOWERS, CONTEXT_LD_SIGNATURES, CONTEXT_DIASPORA) -from federation.entities.activitypub.entities import ActivitypubAccept +from federation.entities.activitypub.constants import CONTEXT +from federation.entities.activitypub.models import Accept from federation.tests.fixtures.keys import PUBKEY from federation.types import UserType @@ -15,12 +14,11 @@ class TestEntitiesConvertToAS2: def test_accept_to_as2(self, activitypubaccept): result = activitypubaccept.to_as2() assert result == { - "@context": CONTEXTS_DEFAULT, + "@context": CONTEXT, "id": "https://localhost/accept", "type": "Accept", "actor": "https://localhost/profile", "object": { - "@context": CONTEXTS_DEFAULT, "id": "https://localhost/follow", "type": "Follow", "actor": "https://localhost/profile", @@ -28,10 +26,10 @@ class TestEntitiesConvertToAS2: }, } - def test_accounce_to_as2(self, activitypubannounce): + def test_announce_to_as2(self, activitypubannounce): result = activitypubannounce.to_as2() assert result == { - "@context": CONTEXTS_DEFAULT, + "@context": CONTEXT, "id": "http://127.0.0.1:8000/post/123456/#create", "type": "Announce", "actor": "http://127.0.0.1:8000/profile/123456/", @@ -40,15 +38,10 @@ class TestEntitiesConvertToAS2: } def test_comment_to_as2(self, activitypubcomment): + activitypubcomment.pre_send() result = activitypubcomment.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -60,9 +53,6 @@ class TestEntitiesConvertToAS2: 'published': '2019-04-27T00:00:00', 'inReplyTo': 'http://127.0.0.1:8000/post/012345/', 'sensitive': False, - 'summary': None, - 'tag': [], - 'url': '', 'source': { 'content': 'raw_content', 'mediaType': 'text/markdown', @@ -73,15 +63,10 @@ class TestEntitiesConvertToAS2: def test_comment_to_as2__url_in_raw_content(self, activitypubcomment): activitypubcomment.raw_content = 'raw_content http://example.com' + activitypubcomment.pre_send() result = activitypubcomment.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -94,9 +79,6 @@ class TestEntitiesConvertToAS2: 'published': '2019-04-27T00:00:00', 'inReplyTo': 'http://127.0.0.1:8000/post/012345/', 'sensitive': False, - 'summary': None, - 'tag': [], - 'url': '', 'source': { 'content': 'raw_content http://example.com', 'mediaType': 'text/markdown', @@ -108,7 +90,7 @@ class TestEntitiesConvertToAS2: def test_follow_to_as2(self, activitypubfollow): result = activitypubfollow.to_as2() assert result == { - "@context": CONTEXTS_DEFAULT, + "@context": CONTEXT, "id": "https://localhost/follow", "type": "Follow", "actor": "https://localhost/profile", @@ -119,7 +101,7 @@ class TestEntitiesConvertToAS2: result = activitypubundofollow.to_as2() result["object"]["id"] = "https://localhost/follow" # Real object will have a random UUID postfix here assert result == { - "@context": CONTEXTS_DEFAULT, + "@context": CONTEXT, "id": "https://localhost/undo", "type": "Undo", "actor": "https://localhost/profile", @@ -132,15 +114,10 @@ class TestEntitiesConvertToAS2: } def test_post_to_as2(self, activitypubpost): + activitypubpost.pre_send() result = activitypubpost.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -150,11 +127,7 @@ class TestEntitiesConvertToAS2: 'attributedTo': 'http://127.0.0.1:8000/profile/123456/', 'content': '

raw_content

', 'published': '2019-04-27T00:00:00', - 'inReplyTo': None, 'sensitive': False, - 'summary': None, - 'tag': [], - 'url': '', 'source': { 'content': '# raw_content', 'mediaType': 'text/markdown', @@ -167,13 +140,7 @@ class TestEntitiesConvertToAS2: activitypubpost_mentions.pre_send() result = activitypubpost_mentions.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -185,9 +152,7 @@ class TestEntitiesConvertToAS2: 'href="http://localhost.local/someone" rel="nofollow" target="_blank">' 'Bob Bobértson

', 'published': '2019-04-27T00:00:00', - 'inReplyTo': None, 'sensitive': False, - 'summary': None, 'tag': [ { "type": "Mention", @@ -210,7 +175,6 @@ class TestEntitiesConvertToAS2: "name": "someone@localhost.local", }, ], - 'url': '', 'source': { 'content': '# raw_content\n\n@{someone@localhost.local} @{http://localhost.local/someone}', 'mediaType': 'text/markdown', @@ -220,15 +184,10 @@ class TestEntitiesConvertToAS2: } def test_post_to_as2__with_tags(self, activitypubpost_tags): + activitypubpost_tags.pre_send() result = activitypubpost_tags.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -246,9 +205,7 @@ class TestEntitiesConvertToAS2: 'noreferrer nofollow" ' 'target="_blank">#barfoo

', 'published': '2019-04-27T00:00:00', - 'inReplyTo': None, 'sensitive': False, - 'summary': None, 'tag': [ { "type": "Hashtag", @@ -261,7 +218,6 @@ class TestEntitiesConvertToAS2: "name": "#foobar", }, ], - 'url': '', 'source': { 'content': '# raw_content\n#foobar\n#barfoo', 'mediaType': 'text/markdown', @@ -271,15 +227,10 @@ class TestEntitiesConvertToAS2: } def test_post_to_as2__with_images(self, activitypubpost_images): + activitypubpost_images.pre_send() result = activitypubpost_images.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -289,16 +240,11 @@ class TestEntitiesConvertToAS2: 'attributedTo': 'http://127.0.0.1:8000/profile/123456/', 'content': '

raw_content

', 'published': '2019-04-27T00:00:00', - 'inReplyTo': None, 'sensitive': False, - 'summary': None, - 'tag': [], - 'url': '', 'attachment': [ { 'type': 'Image', 'mediaType': 'image/jpeg', - 'name': '', 'url': 'foobar', 'pyfed:inlineImage': False, }, @@ -319,16 +265,10 @@ class TestEntitiesConvertToAS2: } def test_post_to_as2__with_diaspora_guid(self, activitypubpost_diaspora_guid): + activitypubpost_diaspora_guid.pre_send() result = activitypubpost_diaspora_guid.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - {'Hashtag': 'as:Hashtag'}, - 'https://w3id.org/security/v1', - {'sensitive': 'as:sensitive'}, - {'diaspora': 'https://diasporafoundation.org/ns/'}, - ], + '@context': CONTEXT, 'type': 'Create', 'id': 'http://127.0.0.1:8000/post/123456/#create', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -339,11 +279,7 @@ class TestEntitiesConvertToAS2: 'attributedTo': 'http://127.0.0.1:8000/profile/123456/', 'content': '

raw_content

', 'published': '2019-04-27T00:00:00', - 'inReplyTo': None, 'sensitive': False, - 'summary': None, - 'tag': [], - 'url': '', 'source': { 'content': 'raw_content', 'mediaType': 'text/markdown', @@ -353,14 +289,10 @@ class TestEntitiesConvertToAS2: } # noinspection PyUnusedLocal - @patch("federation.entities.base.fetch_content_type", return_value="image/jpeg") - def test_profile_to_as2(self, mock_fetch, activitypubprofile): + def test_profile_to_as2(self, activitypubprofile): result = activitypubprofile.to_as2() assert result == { - "@context": CONTEXTS_DEFAULT + [ - CONTEXT_LD_SIGNATURES, - CONTEXT_MANUALLY_APPROVES_FOLLOWERS, - ], + "@context": CONTEXT, "endpoints": { "sharedInbox": "https://example.com/public", }, @@ -376,6 +308,7 @@ class TestEntitiesConvertToAS2: "owner": "https://example.com/bob", "publicKeyPem": PUBKEY, }, + 'published': '2022-09-06T00:00:00', "type": "Person", "url": "https://example.com/bob-bobertson", "summary": "foobar", @@ -383,21 +316,15 @@ class TestEntitiesConvertToAS2: "type": "Image", "url": "urllarge", "mediaType": "image/jpeg", - "name": "", "pyfed:inlineImage": False, } } # noinspection PyUnusedLocal - @patch("federation.entities.base.fetch_content_type", return_value="image/jpeg") - def test_profile_to_as2__with_diaspora_guid(self, mock_fetch, activitypubprofile_diaspora_guid): + def test_profile_to_as2__with_diaspora_guid(self, activitypubprofile_diaspora_guid): result = activitypubprofile_diaspora_guid.to_as2() assert result == { - "@context": CONTEXTS_DEFAULT + [ - CONTEXT_LD_SIGNATURES, - CONTEXT_MANUALLY_APPROVES_FOLLOWERS, - CONTEXT_DIASPORA, - ], + "@context": CONTEXT, "endpoints": { "sharedInbox": "https://example.com/public", }, @@ -415,6 +342,7 @@ class TestEntitiesConvertToAS2: "owner": "https://example.com/bob", "publicKeyPem": PUBKEY, }, + 'published': '2022-09-06T00:00:00', "type": "Person", "url": "https://example.com/bob-bobertson", "summary": "foobar", @@ -422,7 +350,6 @@ class TestEntitiesConvertToAS2: "type": "Image", "url": "urllarge", "mediaType": "image/jpeg", - "name": "", "pyfed:inlineImage": False, } } @@ -430,10 +357,7 @@ class TestEntitiesConvertToAS2: def test_retraction_to_as2(self, activitypubretraction): result = activitypubretraction.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - ], + '@context': CONTEXT, 'type': 'Delete', 'id': 'http://127.0.0.1:8000/post/123456/#delete', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -447,10 +371,7 @@ class TestEntitiesConvertToAS2: def test_retraction_to_as2__announce(self, activitypubretraction_announce): result = activitypubretraction_announce.to_as2() assert result == { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}, - ], + '@context': CONTEXT, 'type': 'Undo', 'id': 'http://127.0.0.1:8000/post/123456/#delete', 'actor': 'http://127.0.0.1:8000/profile/123456/', @@ -463,15 +384,15 @@ class TestEntitiesConvertToAS2: class TestEntitiesPostReceive: - @patch("federation.utils.activitypub.retrieve_and_parse_profile", autospec=True) - @patch("federation.entities.activitypub.entities.handle_send", autospec=True) + @patch("federation.entities.activitypub.models.retrieve_and_parse_profile", autospec=True) + @patch("federation.entities.activitypub.models.handle_send", autospec=True) def test_follow_post_receive__sends_correct_accept_back( self, mock_send, mock_retrieve, activitypubfollow, profile ): mock_retrieve.return_value = profile activitypubfollow.post_receive() args, kwargs = mock_send.call_args_list[0] - assert isinstance(args[0], ActivitypubAccept) + assert isinstance(args[0], Accept) assert args[0].activity_id.startswith("https://example.com/profile#accept-") assert args[0].actor_id == "https://example.com/profile" assert args[0].target_id == "https://localhost/follow" @@ -485,13 +406,13 @@ class TestEntitiesPostReceive: "public": False, }] - @patch("federation.entities.activitypub.entities.bleach.linkify", autospec=True) + @patch("federation.entities.activitypub.models.bleach.linkify", autospec=True) def test_post_post_receive__linkifies_if_not_markdown(self, mock_linkify, activitypubpost): activitypubpost._media_type = 'text/html' activitypubpost.post_receive() mock_linkify.assert_called_once() - @patch("federation.entities.activitypub.entities.bleach.linkify", autospec=True) + @patch("federation.entities.activitypub.models.bleach.linkify", autospec=True) def test_post_post_receive__skips_linkify_if_markdown(self, mock_linkify, activitypubpost): activitypubpost.post_receive() mock_linkify.assert_not_called() diff --git a/federation/tests/entities/activitypub/test_mappers.py b/federation/tests/entities/activitypub/test_mappers.py index 34aa42a..31305a1 100644 --- a/federation/tests/entities/activitypub/test_mappers.py +++ b/federation/tests/entities/activitypub/test_mappers.py @@ -3,9 +3,10 @@ from unittest.mock import patch, Mock import pytest -from federation.entities.activitypub.entities import ( - ActivitypubFollow, ActivitypubAccept, ActivitypubProfile, ActivitypubPost, ActivitypubComment, - ActivitypubRetraction, ActivitypubShare) +#from federation.entities.activitypub.entities import ( +# models.Follow, models.Accept, models.Person, models.Note, models.Note, +# models.Delete, models.Announce) +import federation.entities.activitypub.models as models from federation.entities.activitypub.mappers import message_to_objects, get_outbound_entity from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share from federation.tests.fixtures.payloads import ( @@ -17,7 +18,7 @@ from federation.types import UserType, ReceiverVariant class TestActivitypubEntityMappersReceive: - @patch.object(ActivitypubFollow, "post_receive", autospec=True) + @patch.object(models.Follow, "post_receive", autospec=True) def test_message_to_objects__calls_post_receive_hook(self, mock_post_receive): message_to_objects(ACTIVITYPUB_FOLLOW, "https://example.com/actor") assert mock_post_receive.called @@ -26,7 +27,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_SHARE, "https://mastodon.social/users/jaywink") assert len(entities) == 1 entity = entities[0] - assert isinstance(entity, ActivitypubShare) + assert isinstance(entity, models.Announce) assert entity.actor_id == "https://mastodon.social/users/jaywink" assert entity.target_id == "https://mastodon.social/users/Gargron/statuses/102559779793316012" assert entity.id == "https://mastodon.social/users/jaywink/statuses/102560701449465612/activity" @@ -38,7 +39,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_FOLLOW, "https://example.com/actor") assert len(entities) == 1 entity = entities[0] - assert isinstance(entity, ActivitypubFollow) + assert isinstance(entity, models.Follow) assert entity.actor_id == "https://example.com/actor" assert entity.target_id == "https://example.org/actor" assert entity.following is True @@ -47,7 +48,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_UNDO_FOLLOW, "https://example.com/actor") assert len(entities) == 1 entity = entities[0] - assert isinstance(entity, ActivitypubFollow) + assert isinstance(entity, models.Follow) assert entity.actor_id == "https://example.com/actor" assert entity.target_id == "https://example.org/actor" assert entity.following is False @@ -65,7 +66,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST, "https://diaspodon.fr/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) assert isinstance(post, Post) assert post.raw_content == '

' \ @@ -82,7 +83,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST_WITH_TAGS, "https://diaspodon.fr/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) assert isinstance(post, Post) assert post.raw_content == '

boom #test

' @@ -90,7 +91,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST_WITH_MENTIONS, "https://mastodon.social/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) assert isinstance(post, Post) assert len(post._mentions) == 1 assert list(post._mentions)[0] == "https://dev3.jasonrobinson.me/u/jaywink/" @@ -99,7 +100,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, "https://diaspodon.fr/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) assert isinstance(post, Post) assert post.rendered_content == '

' \ '@jaywink boom

' @@ -111,7 +112,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, "https://diaspodon.fr/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) assert isinstance(post, Post) assert post.rendered_content == '

@jaywink boom

' @@ -126,7 +127,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_POST_IMAGES, "https://mastodon.social/users/jaywink") assert len(entities) == 1 post = entities[0] - assert isinstance(post, ActivitypubPost) + assert isinstance(post, models.Note) # TODO: test video and audio attachment assert len(post._children) == 2 photo = post._children[0] @@ -144,7 +145,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_COMMENT, "https://diaspodon.fr/users/jaywink") assert len(entities) == 1 comment = entities[0] - assert isinstance(comment, ActivitypubComment) + assert isinstance(comment, models.Note) assert isinstance(comment, Comment) assert comment.raw_content == '

' \ @@ -238,7 +239,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_RETRACTION, "https://friendica.feneas.org/profile/jaywink") assert len(entities) == 1 entity = entities[0] - assert isinstance(entity, ActivitypubRetraction) + assert isinstance(entity, models.Delete) assert entity.actor_id == "https://friendica.feneas.org/profile/jaywink" assert entity.target_id == "https://friendica.feneas.org/objects/76158462-165d-3386-aa23-ba2090614385" assert entity.entity_type == "Object" @@ -247,7 +248,7 @@ class TestActivitypubEntityMappersReceive: entities = message_to_objects(ACTIVITYPUB_RETRACTION_SHARE, "https://mastodon.social/users/jaywink") assert len(entities) == 1 entity = entities[0] - assert isinstance(entity, ActivitypubRetraction) + assert isinstance(entity, models.Announce) assert entity.actor_id == "https://mastodon.social/users/jaywink" assert entity.target_id == "https://mastodon.social/users/jaywink/statuses/102571932479036987/activity" assert entity.entity_type == "Object" @@ -296,30 +297,30 @@ class TestActivitypubEntityMappersReceive: class TestGetOutboundEntity: def test_already_fine_entities_are_returned_as_is(self, private_key): - entity = ActivitypubAccept() + entity = models.Accept() entity.validate = Mock() assert get_outbound_entity(entity, private_key) == entity - entity = ActivitypubFollow() + entity = models.Follow() entity.validate = Mock() assert get_outbound_entity(entity, private_key) == entity - entity = ActivitypubProfile() + entity = models.Person() entity.validate = Mock() assert get_outbound_entity(entity, private_key) == entity - @patch.object(ActivitypubAccept, "validate", new=Mock()) + @patch.object(models.Accept, "validate", new=Mock()) def test_accept_is_converted_to_activitypubaccept(self, private_key): entity = Accept() - assert isinstance(get_outbound_entity(entity, private_key), ActivitypubAccept) + assert isinstance(get_outbound_entity(entity, private_key), models.Accept) - @patch.object(ActivitypubFollow, "validate", new=Mock()) + @patch.object(models.Follow, "validate", new=Mock()) def test_follow_is_converted_to_activitypubfollow(self, private_key): entity = Follow() - assert isinstance(get_outbound_entity(entity, private_key), ActivitypubFollow) + assert isinstance(get_outbound_entity(entity, private_key), models.Follow) - @patch.object(ActivitypubProfile, "validate", new=Mock()) + @patch.object(models.Person, "validate", new=Mock()) def test_profile_is_converted_to_activitypubprofile(self, private_key): entity = Profile() - assert isinstance(get_outbound_entity(entity, private_key), ActivitypubProfile) + assert isinstance(get_outbound_entity(entity, private_key), models.Person) def test_entity_is_validated__fail(self, private_key): entity = Share( diff --git a/federation/tests/fixtures/entities.py b/federation/tests/fixtures/entities.py index a594ccb..810356a 100644 --- a/federation/tests/fixtures/entities.py +++ b/federation/tests/fixtures/entities.py @@ -1,11 +1,12 @@ import pytest # noinspection PyPackageRequirements from freezegun import freeze_time +from unittest.mock import patch -from federation.entities.activitypub.entities import ( - ActivitypubPost, ActivitypubAccept, ActivitypubFollow, ActivitypubProfile, ActivitypubComment, - ActivitypubRetraction, ActivitypubShare, ActivitypubImage) -from federation.entities.base import Profile, Post +from federation.entities.activitypub.mappers import get_outbound_entity +import federation.entities.activitypub.models as models +from federation.entities.activitypub.models import make_content_class +from federation.entities.base import Profile, Post, Comment, Retraction from federation.entities.diaspora.entities import ( DiasporaPost, DiasporaComment, DiasporaLike, DiasporaProfile, DiasporaRetraction, DiasporaContact, DiasporaReshare, @@ -18,8 +19,8 @@ from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD @pytest.fixture def activitypubannounce(): with freeze_time("2019-08-05"): - return ActivitypubShare( - activity_id="http://127.0.0.1:8000/post/123456/#create", + return models.Announce( + id="http://127.0.0.1:8000/post/123456/#create", actor_id="http://127.0.0.1:8000/profile/123456/", target_id="http://127.0.0.1:8000/post/012345/", ) @@ -28,7 +29,7 @@ def activitypubannounce(): @pytest.fixture def activitypubcomment(): with freeze_time("2019-04-27"): - return ActivitypubComment( + obj = make_content_class(Comment)( raw_content="raw_content", public=True, provider_display_name="Socialhome", @@ -37,11 +38,13 @@ def activitypubcomment(): actor_id=f"http://127.0.0.1:8000/profile/123456/", target_id="http://127.0.0.1:8000/post/012345/", ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubfollow(): - return ActivitypubFollow( + return models.Follow( activity_id="https://localhost/follow", actor_id="https://localhost/profile", target_id="https://example.com/profile", @@ -50,18 +53,18 @@ def activitypubfollow(): @pytest.fixture def activitypubaccept(activitypubfollow): - return ActivitypubAccept( + return models.Accept( activity_id="https://localhost/accept", actor_id="https://localhost/profile", target_id="https://example.com/follow/1234", - object=activitypubfollow.to_as2(), + object_=activitypubfollow, ) @pytest.fixture def activitypubpost(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content="# raw_content", public=True, provider_display_name="Socialhome", @@ -70,12 +73,14 @@ def activitypubpost(): actor_id=f"http://127.0.0.1:8000/profile/123456/", _media_type="text/markdown", ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubpost_diaspora_guid(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content="raw_content", public=True, provider_display_name="Socialhome", @@ -84,12 +89,14 @@ def activitypubpost_diaspora_guid(): actor_id=f"http://127.0.0.1:8000/profile/123456/", guid="totallyrandomguid", ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubpost_images(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content="raw_content", public=True, provider_display_name="Socialhome", @@ -97,16 +104,18 @@ def activitypubpost_images(): activity_id=f"http://127.0.0.1:8000/post/123456/#create", actor_id=f"http://127.0.0.1:8000/profile/123456/", _children=[ - ActivitypubImage(url="foobar", media_type="image/jpeg"), - ActivitypubImage(url="barfoo", name="spam and eggs", media_type="image/jpeg"), + models.Image(url="foobar", media_type="image/jpeg"), + models.Image(url="barfoo", name="spam and eggs", media_type="image/jpeg"), ], ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubpost_mentions(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content="""# raw_content\n\n@{someone@localhost.local} @{http://localhost.local/someone}""", public=True, provider_display_name="Socialhome", @@ -119,12 +128,14 @@ def activitypubpost_mentions(): "http://localhost.local/someone", } ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubpost_tags(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content="# raw_content\n#foobar\n#barfoo", public=True, provider_display_name="Socialhome", @@ -132,12 +143,14 @@ def activitypubpost_tags(): activity_id=f"http://127.0.0.1:8000/post/123456/#create", actor_id=f"http://127.0.0.1:8000/profile/123456/", ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture def activitypubpost_embedded_images(): with freeze_time("2019-04-27"): - return ActivitypubPost( + obj = make_content_class(Post)( raw_content=""" #Cycling #lauttasaari #sea #sun @@ -158,60 +171,68 @@ https://jasonrobinson.me/media/uploads/2019/07/16/daa24d89-cedf-4fc7-bad8-74a902 activity_id=f"http://127.0.0.1:8000/post/123456/#create", actor_id=f"https://jasonrobinson.me/u/jaywink/", ) + obj.times={'edited':False, 'created':obj.created_at} + return obj @pytest.fixture -def activitypubprofile(): - return ActivitypubProfile( - id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True, - tag_list=["socialfederation", "federation"], image_urls={ - "large": "urllarge", "medium": "urlmedium", "small": "urlsmall" - }, inboxes={ - "private": "https://example.com/bob/private", - "public": "https://example.com/public", - }, public_key=PUBKEY, url="https://example.com/bob-bobertson" - ) +@patch.object(models.base.Image, 'get_media_type', return_value="image/jpeg") +def activitypubprofile(mock_fetch): + with freeze_time("2022-09-06"): + return models.Person( + id="https://example.com/bob/", raw_content="foobar", name="Bob Bobertson", public=True, + tag_list=["socialfederation", "federation"], image_urls={ + "large": "urllarge", "medium": "urlmedium", "small": "urlsmall" + }, inboxes={ + "private": "https://example.com/bob/private", + "public": "https://example.com/public", + }, public_key=PUBKEY, url="https://example.com/bob-bobertson" + ) @pytest.fixture -def activitypubprofile_diaspora_guid(): - return ActivitypubProfile( - id="https://example.com/bob", raw_content="foobar", name="Bob Bobertson", public=True, - tag_list=["socialfederation", "federation"], image_urls={ - "large": "urllarge", "medium": "urlmedium", "small": "urlsmall" - }, inboxes={ - "private": "https://example.com/bob/private", - "public": "https://example.com/public", - }, public_key=PUBKEY, url="https://example.com/bob-bobertson", - guid="totallyrandomguid", handle="bob@example.com", - ) +@patch.object(models.base.Image, 'get_media_type', return_value="image/jpeg") +def activitypubprofile_diaspora_guid(mock_fetch): + with freeze_time("2022-09-06"): + return models.Person( + id="https://example.com/bob/", raw_content="foobar", name="Bob Bobertson", public=True, + tag_list=["socialfederation", "federation"], image_urls={ + "large": "urllarge", "medium": "urlmedium", "small": "urlsmall" + }, inboxes={ + "private": "https://example.com/bob/private", + "public": "https://example.com/public", + }, public_key=PUBKEY, url="https://example.com/bob-bobertson", + guid="totallyrandomguid", handle="bob@example.com", + ) @pytest.fixture def activitypubretraction(): with freeze_time("2019-04-27"): - return ActivitypubRetraction( + obj = Retraction( target_id="http://127.0.0.1:8000/post/123456/", activity_id="http://127.0.0.1:8000/post/123456/#delete", actor_id="http://127.0.0.1:8000/profile/123456/", entity_type="Post", ) + return get_outbound_entity(obj, None) @pytest.fixture def activitypubretraction_announce(): with freeze_time("2019-04-27"): - return ActivitypubRetraction( + obj = Retraction( target_id="http://127.0.0.1:8000/post/123456/activity", activity_id="http://127.0.0.1:8000/post/123456/#delete", actor_id="http://127.0.0.1:8000/profile/123456/", entity_type="Share", ) + return get_outbound_entity(obj, None) @pytest.fixture def activitypubundofollow(): - return ActivitypubFollow( + return models.Follow( activity_id="https://localhost/undo", actor_id="https://localhost/profile", target_id="https://example.com/profile", diff --git a/federation/tests/utils/test_activitypub.py b/federation/tests/utils/test_activitypub.py index e84eaaf..a4c58ce 100644 --- a/federation/tests/utils/test_activitypub.py +++ b/federation/tests/utils/test_activitypub.py @@ -3,7 +3,7 @@ from unittest.mock import patch, Mock import pytest -from federation.entities.activitypub.entities import ActivitypubFollow, ActivitypubPost +from federation.entities.activitypub.models import Follow, Note from federation.tests.fixtures.payloads import ( ACTIVITYPUB_FOLLOW, ACTIVITYPUB_POST, ACTIVITYPUB_POST_OBJECT, ACTIVITYPUB_POST_OBJECT_IMAGES) from federation.utils.activitypub import ( @@ -53,24 +53,24 @@ class TestRetrieveAndParseDocument: @patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=( json.dumps(ACTIVITYPUB_FOLLOW), None, None), ) - @patch.object(ActivitypubFollow, "post_receive") + @patch.object(Follow, "post_receive") def test_returns_entity_for_valid_document__follow(self, mock_post_receive, mock_fetch): entity = retrieve_and_parse_document("https://example.com/foobar") - assert isinstance(entity, ActivitypubFollow) + assert isinstance(entity, Follow) @patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=( json.dumps(ACTIVITYPUB_POST_OBJECT), None, None), ) def test_returns_entity_for_valid_document__post__without_activity(self, mock_fetch): entity = retrieve_and_parse_document("https://example.com/foobar") - assert isinstance(entity, ActivitypubPost) + assert isinstance(entity, Note) @patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=( json.dumps(ACTIVITYPUB_POST_OBJECT_IMAGES), None, None), ) def test_returns_entity_for_valid_document__post__without_activity__with_images(self, mock_fetch): entity = retrieve_and_parse_document("https://example.com/foobar") - assert isinstance(entity, ActivitypubPost) + assert isinstance(entity, Note) assert len(entity._children) == 1 assert entity._children[0].url == "https://files.mastodon.social/media_attachments/files/017/792/237/original" \ "/foobar.jpg" @@ -80,7 +80,7 @@ class TestRetrieveAndParseDocument: ) def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch): entity = retrieve_and_parse_document("https://example.com/foobar") - assert isinstance(entity, ActivitypubPost) + assert isinstance(entity, Note) @patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=('{"foo": "bar"}', None, None)) def test_returns_none_for_invalid_document(self, mock_fetch):