"""Unit tests for webmention.py.""" import copy from datetime import timedelta from unittest.mock import ANY, patch from flask import get_flashed_messages from google.cloud import ndb from granary import as1, as2, atom, microformats2, rss from oauth_dropins.webutil import util from oauth_dropins.webutil import appengine_info from oauth_dropins.webutil.testutil import NOW, NOW_SECONDS, requests_response from oauth_dropins.webutil.util import json_dumps, json_loads import requests from werkzeug.exceptions import BadGateway, BadRequest # import first so that Fake is defined before URL routes are registered from . import testutil from activitypub import ActivityPub import common from common import CONTENT_TYPE_HTML, TASKS_LOCATION from flask_app import app from models import Follower, Object import web from web import Web from . import test_activitypub from .testutil import Fake, TestCase FULL_REDIR = requests_response( status=302, redirected_url='http://localhost/.well-known/webfinger?resource=acct:user.com@user.com') ACTOR_HTML = """\
Ms. ☕ Baz """ ACTOR_HTML_RESP = requests_response(ACTOR_HTML, url='https://user.com/') ACTOR_HTML_METAFORMATS = """\hello i am a post
Ms. ☕ Baz
""" NOTE = requests_response(NOTE_HTML, url='https://user.com/post') NOTE_MF2 = util.parse_mf2(NOTE_HTML)['items'][0] NOTE_AS1 = microformats2.json_to_object(NOTE_MF2) NOTE_AS1['id'] = 'https://user.com/post' NOTE_AS1['author']['id'] = 'user.com' CREATE_AS1 = { 'objectType': 'activity', 'verb': 'post', 'id': 'https://user.com/post#bridgy-fed-create', 'actor': ACTOR_AS1_UNWRAPPED, 'object': copy.deepcopy(NOTE_AS1), 'published': '2022-01-02T03:04:05+00:00', } NOTE_AS2 = { 'type': 'Note', 'id': 'http://localhost/r/https://user.com/post', 'url': 'http://localhost/r/https://user.com/post', 'attributedTo': 'http://localhost/user.com', 'name': 'hello i am a post', 'content': 'hello i am a post', 'contentMap': {'en': 'hello i am a post'}, 'to': [as2.PUBLIC_AUDIENCE], } CREATE_AS2 = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Create', 'id': 'http://localhost/r/https://user.com/post#bridgy-fed-create', 'actor': 'http://localhost/user.com', 'object': NOTE_AS2, 'published': '2022-01-02T03:04:05+00:00', 'to': [as2.PUBLIC_AUDIENCE], } UPDATE_AS2 = copy.deepcopy(CREATE_AS2) UPDATE_AS2.update({ 'type': 'Update', 'id': 'http://localhost/r/https://user.com/post#bridgy-fed-update-2022-01-02T03:04:05+00:00', }) del UPDATE_AS2['published'] UPDATE_AS2['object']['updated'] = NOW.isoformat() NOT_FEDIVERSE = requests_response("""\ Mr. Notnothing to see here except link
""", url='https://user.com/post') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(304, got.status_code) self.assertEqual(orig_count, Object.query().count()) mock_get.assert_has_calls((self.req('https://user.com/post'),)) def test_no_targets(self, mock_get, mock_post): mock_get.return_value = requests_response("""no one to send to!
""", url='https://user.com/post') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(204, got.status_code) mock_get.assert_has_calls((self.req('https://user.com/post'),)) def test_bad_target_url(self, mock_get, mock_post): mock_get.side_effect = ( requests_response( REPLY_HTML.replace('https://mas.to/toot', 'bad:nope')\ .replace('http://no.t/fediverse', ''), content_type=CONTENT_TYPE_HTML, url='https://user.com/reply'), ValueError('foo bar'), # AS2 fetch ValueError('foo bar'), # HTML fetch ) got = self.post('/queue/webmention', data={'source': 'https://user.com/reply'}) self.assertEqual(204, got.status_code) self.assert_object('https://user.com/reply', source_protocol='web', type='comment', labels=[], ignore=['mf2', 'our_as1'], ) self.assert_object('https://user.com/reply#bridgy-fed-create', source_protocol='web', our_as1=CREATE_REPLY_AS1, type='post', labels=['activity', 'user'], ignore=['our_as1'], status='ignored', users=[self.user.key], ) def test_target_fetch_fails(self, mock_get, mock_post): mock_get.side_effect = [ requests_response( REPLY_HTML.replace('https://mas.to/toot', 'bad:nope'), url='https://user.com/post'), # http://no.t/fediverse AP protocol discovery requests.Timeout('foo bar'), # http://no.t/fediverse web protocol discovery requests.Timeout('foo bar'), ] got = self.post('/queue/webmention', data={'source': 'https://user.com/reply'}) self.assertEqual(204, got.status_code) def test_target_fetch_has_no_content_type(self, mock_get, mock_post): Object(id='http://no.t/fediverse', mf2=NOTE_MF2, source_protocol='web').put() no_content_type = requests_response(REPLY_HTML, content_type='') mock_get.side_effect = ( requests_response(REPLY_HTML, url='https://user.com/reply'), # requests: no_content_type, # https://mas.to/toot AP protocol discovery no_content_type, # https://mas.to/toot Web protocol discovery no_content_type, # https://user.com/ webmention discovery no_content_type, # http://no.t/fediverse webmention discovery ) got = self.post('/queue/webmention', data={'source': 'https://user.com/reply'}) self.assertEqual(204, got.status_code) mock_post.assert_not_called() def test_missing_backlink(self, mock_get, mock_post): orig_count = Object.query().count() mock_get.return_value = requests_response( REPLY_HTML.replace('', ''), url='https://user.com/reply') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(304, got.status_code) self.assertEqual(orig_count, Object.query().count()) mock_get.assert_has_calls((self.req('https://user.com/reply'),)) def test_backlink_without_trailing_slash(self, mock_get, mock_post): mock_get.return_value = requests_response( REPLY_HTML.replace('', ''), content_type=CONTENT_TYPE_HTML, url='https://user.com/reply') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(204, got.status_code) def test_create_reply(self, mock_get, mock_post): mock_get.side_effect = ACTIVITYPUB_GETS mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/reply'), self.as2_req('http://no.t/fediverse'), self.req('http://no.t/fediverse'), self.as2_req('https://mas.to/toot'), self.as2_req('https://mas.to/author'), )) self.assert_deliveries(mock_post, ['https://mas.to/inbox'], AS2_CREATE) self.assert_object('https://user.com/reply', source_protocol='web', our_as1=REPLY_AS1, type='comment', ) author = ndb.Key(ActivityPub, 'https://mas.to/author') self.assert_object('https://user.com/reply#bridgy-fed-create', users=[self.user.key], notify=[author], source_protocol='web', status='complete', our_as1=CREATE_REPLY_AS1, delivered=['https://mas.to/inbox'], type='post', ) def test_update_reply(self, mock_get, mock_post): self.make_followers() mf2 = { 'properties': { 'content': ['other'], }, } Object(id='https://user.com/reply', status='complete', mf2=mf2).put() mock_get.side_effect = ACTIVITYPUB_GETS mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) self.assertEqual(1, mock_post.call_count) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(AS2_UPDATE, json_loads(kwargs['data'])) def test_redo_repost_isnt_update(self, mock_get, mock_post): """Like and Announce shouldn't use Update, they should just resend as is.""" Object(id='https://user.com/repost', mf2={}, status='complete').put() mock_get.side_effect = [REPOST, TOOT_AS2, ACTOR] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/repost', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) self.assert_deliveries(mock_post, ['https://mas.to/inbox'], REPOST_AS2, ignore=['cc']) def test_skip_update_if_content_unchanged(self, mock_get, mock_post): """https://github.com/snarfed/bridgy-fed/issues/78""" self.store_object(id='https://user.com/reply', mf2=REPLY_MF2) self.store_object(id='https://user.com/reply#bridgy-fed-create', status='complete') mock_get.side_effect = ACTIVITYPUB_GETS got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(204, got.status_code) mock_post.assert_not_called() def test_force_with_content_unchanged_sends_create(self, mock_get, mock_post): Object(id='https://user.com/reply', mf2=REPLY_MF2).put() mock_get.side_effect = ACTIVITYPUB_GETS mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', 'force': '', }) self.assertEqual(202, got.status_code) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(AS2_CREATE, json_loads(kwargs['data'])) def test_create_reply_attributed_to_id_only(self, mock_get, mock_post): """Based on PeerTube's AS2. https://github.com/snarfed/bridgy-fed/issues/40 """ toot_as2_data = copy.deepcopy(TOOT_AS2_DATA) del toot_as2_data['actor'] toot_as2_data['attributedTo'] = { 'type': 'Person', 'id': 'https://mas.to/author', } mock_get.side_effect = [ REPLY, NOT_FEDIVERSE, # AP NOT_FEDIVERSE, # Web self.as2_resp(toot_as2_data), # AP ACTOR, ] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/reply'), self.as2_req('http://no.t/fediverse'), self.req('http://no.t/fediverse'), self.as2_req('https://mas.to/toot'), self.as2_req('https://mas.to/author'), )) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(AS2_CREATE, json_loads(kwargs['data'])) def test_repost(self, mock_get, mock_post): self._test_repost(REPOST_HTML, REPOST_AS2, mock_get, mock_post) def test_repost_composite_hcite(self, mock_get, mock_post): self._test_repost(REPOST_HCITE_HTML, REPOST_AS2, mock_get, mock_post) def _test_repost(self, html, expected_as2, mock_get, mock_post): self.make_followers() REPOSTED_ACTOR = self.as2_resp({ 'type': 'Person', 'name': 'Mas To Foo', 'id': 'https://mas.to/@foo', 'inbox': 'https://mas.to/inbox', }) mock_get.side_effect = [ requests_response(html, url='https://user.com/repost'), TOOT_AS2, ACTOR, REPOSTED_ACTOR, ] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/repost', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/repost'), self.as2_req('https://mas.to/toot/id'), self.as2_req('https://mas.to/author'), )) inboxes = ('https://inbox', 'https://public/inbox', 'https://shared/inbox', 'https://mas.to/inbox') self.assert_deliveries(mock_post, inboxes, expected_as2, ignore=['cc']) for args, kwargs in mock_get.call_args_list[1:]: with self.subTest(url=args[0]): rsa_key = kwargs['auth'].header_signer._rsa._key self.assertEqual(self.user.private_pem(), rsa_key.exportKey()) mf2 = util.parse_mf2(html)['items'][0] author_key = ndb.Key('ActivityPub', 'https://mas.to/author') self.assert_object('https://user.com/repost', users=[self.user.key], notify=[author_key], feed=self.followers, source_protocol='web', status='complete', mf2=mf2, delivered=inboxes, type='share', object_ids=['https://mas.to/toot/id'], labels=['user', 'activity', 'notification', 'feed'], ignore=['our_as1'], ) def test_link_rel_alternate_as2(self, mock_get, mock_post): mock_get.side_effect = [ REPLY, NOT_FEDIVERSE, # AP NOT_FEDIVERSE, # Web TOOT_HTML, # AP TOOT_AS2, # AP via rel-alternate ACTOR, ] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/reply', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/reply'), self.as2_req('http://no.t/fediverse'), self.req('http://no.t/fediverse'), self.as2_req('https://mas.to/toot'), self.as2_req('https://mas.to/toot/id', headers=as2.CONNEG_HEADERS), self.as2_req('https://mas.to/author'), )) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(AS2_CREATE, json_loads(kwargs['data'])) def test_like_stored_object(self, mock_get, mock_post): Object(id='https://mas.to/toot', source_protocol='ap').put() Object(id='https://user.com/', mf2=ACTOR_MF2).put() mock_get.side_effect = [ LIKE, ACTOR_HTML_RESP, ] got = self.post('/queue/webmention', data={ 'source': 'https://user.com/like', 'target': 'https://fed.brid.gy/', }) self.assertEqual(204, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/like'), )) mock_post.assert_not_called() self.assert_object('https://user.com/like', users=[self.user.key], source_protocol='web', mf2=LIKE_MF2, type='like', labels=['activity', 'user'], status='ignored', ignore=['our_as1'], ) def test_post_type_discovery_multiple_types(self, mock_get, mock_post): self.make_followers() mock_get.return_value = requests_response( NOTE_HTML.replace('', """ """), url='https://user.com/post') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/multiple', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) inboxes = ['https://inbox', 'https://public/inbox', 'https://shared/inbox'] expected = { **NOTE_AS2, 'attributedTo': None, 'type': 'Create', 'actor': 'http://localhost/user.com', # TODO: this is an awkward wart left over from the multi-type mf2. # remove it eventually. 'object': { 'targetUrl': 'http://bob.com/post', 'to': ['https://www.w3.org/ns/activitystreams#Public'], }, } del expected['contentMap'] self.assert_deliveries(mock_post, inboxes, expected) def test_create_default_url_to_wm_source(self, mock_get, mock_post): """Source post has no u-url. AS2 id should default to webmention source.""" missing_url = requests_response("""\ reposted! Ms. ☕ Baz """, url='https://user.com/repost') mock_get.side_effect = [missing_url, TOOT_AS2, ACTOR] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/repost', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(REPOST_AS2, json_loads(kwargs['data'])) def test_create_author_only_url(self, mock_get, mock_post): """Mf2 author property is just a URL. We should run full authorship. https://indieweb.org/authorship """ repost = requests_response("""\ reposted! """, url='https://user.com/repost') mock_get.side_effect = [repost, ACTOR, TOOT_AS2, ACTOR] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/repost', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) args, kwargs = mock_post.call_args self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(REPOST_AS2, json_loads(kwargs['data'])) def test_create_no_author(self, mock_get, mock_post): """No mf2 author. We should default to the user's homepage.""" mock_get.side_effect = [ requests_response("""\ reposted! """, url='https://user.com/repost'), NOT_FEDIVERSE, TOOT_AS2, ACTOR, ] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://user.com/repost', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) repost_mf2 = copy.deepcopy(REPOST_MF2) repost_mf2['properties']['author'] = ['https://user.com/'] self.assert_object('https://user.com/repost', users=[self.user.key], source_protocol='web', mf2=repost_mf2, # includes author https://user.com/ type='share', labels=['activity', 'user'], notify=[ndb.Key('ActivityPub', 'https://mas.to/author')], delivered=['https://mas.to/inbox'], status='complete', ignore=['our_as1'], ) def test_create_non_domain_author(self, mock_get, mock_post): """Author is a page on the user's domain.""" self.make_followers() mock_get.side_effect = [ requests_response(NOTE_HTML.replace( '', '' ), url='https://user.com/post'), ACTOR_HTML_RESP, TOOT_AS2, ACTOR, ] got = self.post('/queue/webmention', data={ 'source': 'https://user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) inboxes = ['https://inbox', 'https://public/inbox', 'https://shared/inbox'] expected_create_as1 = copy.deepcopy(CREATE_AS1) expected_create_as1['object']['author']['id'] = 'https://user.com/' self.assert_object('https://user.com/post#bridgy-fed-create', users=[self.user.key], source_protocol='web', our_as1=expected_create_as1, type='post', labels=['activity', 'user'], delivered=inboxes, status='complete', ) def test_create_post_use_instead_strip_www(self, mock_get, mock_post): self.user.obj.mf2 = { 'type': ['h-card'], 'properties': { # this is the key part to test; Object.as1 uses this as id 'url': ['https://www.user.com/'], 'name': ['Ms. ☕ Baz'], }, } self.user.obj.put() self.make_user('www.user.com', cls=Web, use_instead=self.user.key) self.make_followers() note_html = NOTE_HTML.replace('https://user.com/', 'https://www.user.com/') mock_get.side_effect = [ requests_response(note_html, url='https://www.user.com/post'), ACTOR, ] mock_post.return_value = requests_response('abc xyz') got = self.post('/queue/webmention', data={ 'source': 'https://www.user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://www.user.com/post'), )) inboxes = ('https://inbox', 'https://public/inbox', 'https://shared/inbox') create_as2 = copy.deepcopy(CREATE_AS2) create_as2['id'] = 'http://localhost/r/https://www.user.com/post#bridgy-fed-create' create_as2['object'].update({ 'id': 'http://localhost/r/https://www.user.com/post', 'url': 'http://localhost/r/https://www.user.com/post', }) self.assert_deliveries(mock_post, inboxes, create_as2) def test_create_post(self, mock_get, mock_post): mock_get.side_effect = [NOTE, ACTOR] mock_post.return_value = requests_response('abc xyz') self.make_followers() got = self.post('/queue/webmention', data={ 'source': 'https://user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/post'), )) inboxes = ('https://inbox', 'https://public/inbox', 'https://shared/inbox') self.assert_deliveries(mock_post, inboxes, CREATE_AS2) self.assert_object('https://user.com/post', our_as1=NOTE_AS1, feed=self.followers, type='note', source_protocol='web', ) self.assert_object('https://user.com/post#bridgy-fed-create', users=[self.user.key], source_protocol='web', status='complete', our_as1=CREATE_AS1, delivered=inboxes, type='post', ) def test_update_post(self, mock_get, mock_post): mock_get.side_effect = [NOTE, ACTOR] mock_post.return_value = requests_response('abc xyz') mf2 = copy.deepcopy(NOTE_MF2) mf2['properties']['content'] = 'different' Object(id='https://user.com/post', users=[self.user.key], mf2=mf2).put() self.make_followers() got = self.post('/queue/webmention', data={ 'source': 'https://user.com/post', 'target': 'https://fed.brid.gy/', }) self.assertEqual(202, got.status_code) mock_get.assert_has_calls(( self.req('https://user.com/post'), )) inboxes = ('https://inbox', 'https://public/inbox', 'https://shared/inbox') self.assert_deliveries(mock_post, inboxes, UPDATE_AS2) update_as1 = { 'objectType': 'activity', 'verb': 'update', 'id': 'https://user.com/post#bridgy-fed-update-2022-01-02T03:04:05+00:00', 'actor': ACTOR_AS1_UNWRAPPED, 'object': { **NOTE_AS1, 'updated': '2022-01-02T03:04:05+00:00', }, } self.assert_object( f'https://user.com/post#bridgy-fed-update-2022-01-02T03:04:05+00:00', users=[self.user.key], source_protocol='web', status='complete', our_as1=update_as1, delivered=inboxes, type='update', labels=['user', 'activity'], ) def test_create_with_image(self, mock_get, mock_post): create_html = NOTE_HTML.replace( '', '\nnothing to see here except link