From b8be570d6695ad9ad5da9b5b517be732e2465672 Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Sat, 10 Jul 2021 08:07:40 -0700 Subject: [PATCH] flask: port activitypub --- activitypub.py | 301 +++++++++++++++++++------------------- add_webmention.py | 7 +- common.py | 10 +- redirect.py | 2 +- salmon.py | 2 +- tests/test_activitypub.py | 118 +++++++-------- 6 files changed, 221 insertions(+), 219 deletions(-) diff --git a/activitypub.py b/activitypub.py index 0574298..c0c27f8 100644 --- a/activitypub.py +++ b/activitypub.py @@ -4,7 +4,9 @@ from base64 import b64encode import datetime from hashlib import sha256 import logging +import re +from flask import request from google.cloud import ndb from granary import as2, microformats2 import mf2util @@ -13,7 +15,9 @@ from oauth_dropins.webutil.handlers import cache_response from oauth_dropins.webutil.util import json_dumps, json_loads import webapp2 +from app import app, cache import common +from common import error, redirect_unwrap, redirect_wrap from models import Follower, MagicKey from httpsig.requests_auth import HTTPSignatureAuth @@ -75,182 +79,185 @@ def send(activity, inbox_url, user_domain): headers=headers) -class ActorHandler(): +@app.route('/') +@cache.cached(CACHE_TIME.total_seconds()) +def actor(domain): """Serves /[DOMAIN], fetches its mf2, converts to AS Actor, and serves it.""" + if not re.match(common.DOMAIN_RE, domain): + return error(f'{acct} is not a domain', 404) - @cache_response(CACHE_TIME) - def get(self, domain): - tld = domain.split('.')[-1] - if tld in common.TLD_BLOCKLIST: - self.error('', status=404) + tld = domain.split('.')[-1] + if tld in common.TLD_BLOCKLIST: + return error('', status=404) - mf2 = util.fetch_mf2('http://%s/' % domain, gateway=True, - headers=common.HEADERS) - # logging.info('Parsed mf2 for %s: %s', resp.url, json_dumps(mf2, indent=2)) + mf2 = util.fetch_mf2('http://%s/' % domain, gateway=True, + headers=common.HEADERS) + # logging.info('Parsed mf2 for %s: %s', resp.url, json_dumps(mf2, indent=2)) - hcard = mf2util.representative_hcard(mf2, mf2['url']) - logging.info('Representative h-card: %s', json_dumps(hcard, indent=2)) - if not hcard: - self.error("""\ -Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on %s""" % mf2['url']) + hcard = mf2util.representative_hcard(mf2, mf2['url']) + logging.info('Representative h-card: %s', json_dumps(hcard, indent=2)) + if not hcard: + return error("""\ +Coul find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on %s""" % mf2['url']) - key = MagicKey.get_or_create(domain) - obj = self.postprocess_as2(as2.from_as1(microformats2.json_to_object(hcard)), - key=key) - obj.update({ - 'inbox': f'{request.host_url}{domain}/inbox', - 'outbox': f'{request.host_url}{domain}/outbox', - 'following': f'{request.host_url}{domain}/following', - 'followers': f'{request.host_url}{domain}/followers', - }) - logging.info('Returning: %s', json_dumps(obj, indent=2)) + key = MagicKey.get_or_create(domain) + obj = common.postprocess_as2( + as2.from_as1(microformats2.json_to_object(hcard)), key=key) + obj.update({ + 'inbox': f'{request.host_url}{domain}/inbox', + 'outbox': f'{request.host_url}{domain}/outbox', + 'following': f'{request.host_url}{domain}/following', + 'followers': f'{request.host_url}{domain}/followers', + }) + logging.info('Returning: %s', json_dumps(obj, indent=2)) - self.response.headers.update({ - 'Content-Type': common.CONTENT_TYPE_AS2, - 'Access-Control-Allow-Origin': '*', - }) - self.response.write(json_dumps(obj, indent=2)) + return (obj, { + 'Content-Type': common.CONTENT_TYPE_AS2, + 'Access-Control-Allow-Origin': '*', + }) -class InboxHandler(): +@app.route('//inbox', methods=['POST']) +def inbox(domain): """Accepts POSTs to /[DOMAIN]/inbox and converts to outbound webmentions.""" - def post(self, domain): - logging.info('Got: %s', self.request.body) + body = request.get_data(as_text=True) + logging.info(f'Got: {body}') - # parse and validate AS2 activity - try: - activity = json_loads(self.request.body) - assert activity - except (TypeError, ValueError, AssertionError): - self.error("Couldn't parse body as JSON", exc_info=True) + if not re.match(common.DOMAIN_RE, domain): + return error(f'{acct} is not a domain', 404) - obj = activity.get('object') or {} - if isinstance(obj, str): - obj = {'id': obj} + # parse and validate AS2 activity + try: + activity = request.json + assert activity + except (TypeError, ValueError, AssertionError): + return error("Couldn't parse body as JSON", exc_info=True) - type = activity.get('type') - if type == 'Accept': # eg in response to a Follow - return # noop - if type == 'Create': - type = obj.get('type') - elif type not in SUPPORTED_TYPES: - self.error('Sorry, %s activities are not supported yet.' % type, - status=501) + obj = activity.get('object') or {} + if isinstance(obj, str): + obj = {'id': obj} - # TODO: verify signature if there is one + type = activity.get('type') + if type == 'Accept': # eg in response to a Follow + return # noop + if type == 'Create': + type = obj.get('type') + elif type not in SUPPORTED_TYPES: + return error('Sorry, %s activities are not supported yet.' % type, + status=501) - if type == 'Undo' and obj.get('type') == 'Follow': - # skip actor fetch below; we don't need it to undo a follow - return self.undo_follow(self.redirect_unwrap(activity)) - elif type == 'Delete': - id = obj.get('id') + # TODO: verify signature if there is one - # !!! temporarily disabled actually deleting Followers below because - # mastodon.social sends Deletes for every Bridgy Fed account, all at - # basically the same time, and we have many Follower objects, so we - # have to do this table scan for each one, so the requests take a - # long time and end up spawning extra App Engine instances that we - # get billed for. and the Delete requests are almost never for - # followers we have. TODO: revisit this and do it right. + if type == 'Undo' and obj.get('type') == 'Follow': + # skip actor fetch below; we don't need it to undo a follow + undo_follow(redirect_unwrap(activity)) + return '' + elif type == 'Delete': + id = obj.get('id') - # if isinstance(id, str): - # # assume this is an actor - # # https://github.com/snarfed/bridgy-fed/issues/63 - # for key in Follower.query().iter(keys_only=True): - # if key.id().split(' ')[-1] == id: - # key.delete() - return + # !!! temporarily disabled actually deleting Followers below because + # mastodon.social sends Deletes for every Bridgy Fed account, all at + # basically the same time, and we have many Follower objects, so we + # have to do this table scan for each one, so the requests take a + # long time and end up spawning extra App Engine instances that we + # get billed for. and the Delete requests are almost never for + # followers we have. TODO: revisit this and do it right. - # fetch actor if necessary so we have name, profile photo, etc - for elem in obj, activity: - actor = elem.get('actor') - if actor and isinstance(actor, str): - elem['actor'] = common.get_as2(actor).json() + # if isinstance(id, str): + # # assume this is an actor + # # https://github.com/snarfed/bridgy-fed/issues/63 + # for key in Follower.query().iter(keys_only=True): + # if key.id().split(' ')[-1] == id: + # key.delete() + return '' - activity_unwrapped = self.redirect_unwrap(activity) - if type == 'Follow': - return self.accept_follow(activity, activity_unwrapped) + # fetch actor if necessary so we have name, profile photo, etc + for elem in obj, activity: + actor = elem.get('actor') + if actor and isinstance(actor, str): + elem['actor'] = common.get_as2(actor).json() - # send webmentions to each target - as1 = as2.to_as1(activity) - self.send_webmentions(as1, proxy=True, protocol='activitypub', - source_as2=json_dumps(activity_unwrapped)) + activity_unwrapped = redirect_unwrap(activity) + if type == 'Follow': + return accept_follow(activity, activity_unwrapped) - def accept_follow(self, follow, follow_unwrapped): - """Replies to an AP Follow request with an Accept request. + # send webmentions to each target + as1 = as2.to_as1(activity) + common.send_webmentions(as1, proxy=True, protocol='activitypub', + source_as2=json_dumps(activity_unwrapped)) - Args: - follow: dict, AP Follow activity - follow_unwrapped: dict, same, except with redirect URLs unwrapped - """ - logging.info('Replying to Follow with Accept') + return '' - followee = follow.get('object') - followee_unwrapped = follow_unwrapped.get('object') - follower = follow.get('actor') - if not followee or not followee_unwrapped or not follower: - self.error('Follow activity requires object and actor. Got: %s' % follow) - inbox = follower.get('inbox') - follower_id = follower.get('id') - if not inbox or not follower_id: - self.error('Follow actor requires id and inbox. Got: %s', follower) +def accept_follow(follow, follow_unwrapped): + """Replies to an AP Follow request with an Accept request. - # store Follower - user_domain = util.domain_from_link(followee_unwrapped) - Follower.get_or_create(user_domain, follower_id, last_follow=json_dumps(follow)) + Args: + follow: dict, AP Follow activity + follow_unwrapped: dict, same, except with redirect URLs unwrapped + """ + logging.info('Replying to Follow with Accept') - # send AP Accept - accept = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': util.tag_uri(self.request.host, 'accept/%s/%s' % ( - (user_domain, follow.get('id')))), - 'type': 'Accept', - 'actor': followee, - 'object': { - 'type': 'Follow', - 'actor': follower_id, - 'object': followee, - } + followee = follow.get('object') + followee_unwrapped = follow_unwrapped.get('object') + follower = follow.get('actor') + if not followee or not followee_unwrapped or not follower: + return error('Follow activity requires object and actor. Got: %s' % follow) + + inbox = follower.get('inbox') + follower_id = follower.get('id') + if not inbox or not follower_id: + return error('Follow actor requires id and inbox. Got: %s', follower) + + # store Follower + user_domain = util.domain_from_link(followee_unwrapped) + Follower.get_or_create(user_domain, follower_id, last_follow=json_dumps(follow)) + + # send AP Accept + accept = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': util.tag_uri(request.host, 'accept/%s/%s' % ( + (user_domain, follow.get('id')))), + 'type': 'Accept', + 'actor': followee, + 'object': { + 'type': 'Follow', + 'actor': follower_id, + 'object': followee, } - resp = send(accept, inbox, user_domain) - self.response.status_int = resp.status_code - self.response.write(resp.text) + } + resp = send(accept, inbox, user_domain) - # send webmention - self.send_webmentions(as2.to_as1(follow), proxy=True, protocol='activitypub', - source_as2=json_dumps(follow_unwrapped)) + # send webmention + common.send_webmentions(as2.to_as1(follow), proxy=True, protocol='activitypub', + source_as2=json_dumps(follow_unwrapped)) - @ndb.transactional() - def undo_follow(self, undo_unwrapped): - """Replies to an AP Follow request with an Accept request. - - Args: - undo_unwrapped: dict, AP Undo activity with redirect URLs unwrapped - """ - logging.info('Undoing Follow') - - follow = undo_unwrapped.get('object', {}) - follower = follow.get('actor') - followee = follow.get('object') - if not follower or not followee: - self.error('Undo of Follow requires object with actor and object. Got: %s' % follow) - - # deactivate Follower - user_domain = util.domain_from_link(followee) - follower_obj = Follower.get_by_id(Follower._id(user_domain, follower)) - if follower_obj: - logging.info('Marking %s as inactive' % follower_obj.key) - follower_obj.status = 'inactive' - follower_obj.put() - else: - logging.warning('No Follower found for %s %s', user_domain, follower) + return resp.text, resp.status_code - # TODO send webmention with 410 of u-follow +@ndb.transactional() +def undo_follow(undo_unwrapped): + """Replies to an AP Follow request with an Accept request. + Args: + undo_unwrapped: dict, AP Undo activity with redirect URLs unwrapped + """ + logging.info('Undoing Follow') -ROUTES = [ - (r'/%s/?' % common.DOMAIN_RE, ActorHandler), - (r'/%s/inbox' % common.DOMAIN_RE, InboxHandler), -] + follow = undo_unwrapped.get('object', {}) + follower = follow.get('actor') + followee = follow.get('object') + if not follower or not followee: + return error('Undo of Follow requires object with actor and object. Got: %s' % follow) + + # deactivate Follower + user_domain = util.domain_from_link(followee) + follower_obj = Follower.get_by_id(Follower._id(user_domain, follower)) + if follower_obj: + logging.info('Marking %s as inactive' % follower_obj.key) + follower_obj.status = 'inactive' + follower_obj.put() + else: + logging.warning('No Follower found for %s %s', user_domain, follower) + + # TODO send webmention with 410 of u-follow diff --git a/add_webmention.py b/add_webmention.py index 97d7af6..68c8359 100644 --- a/add_webmention.py +++ b/add_webmention.py @@ -9,6 +9,7 @@ import requests from app import app, cache import common +from common import error LINK_HEADER = '<%s>; rel="webmention"' CACHE_TIME = datetime.timedelta(seconds=15) @@ -21,14 +22,14 @@ def add_wm(url=None): """Proxies HTTP requests and adds Link header to our webmention endpoint.""" url = urllib.parse.unquote(url) if not url.startswith('http://') and not url.startswith('https://'): - common.error('URL must start with http:// or https://') + return error('URL must start with http:// or https://') try: got = common.requests_get(url) except requests.exceptions.Timeout as e: - common.error(str(e), status=504, exc_info=True) + return error(str(e), status=504, exc_info=True) except requests.exceptions.RequestException as e: - common.error(str(e), status=502, exc_info=True) + return error(str(e), status=502, exc_info=True) resp = flask.make_response(got.content, got.status_code, dict(got.headers)) resp.headers.add('Link', LINK_HEADER % (request.args.get('endpoint') or diff --git a/common.py b/common.py index 66470b5..05217fd 100644 --- a/common.py +++ b/common.py @@ -169,7 +169,7 @@ def error(msg, status=None, exc_info=False): if not status: status = 400 logging.info('Returning %s: %s' % (status, msg), exc_info=exc_info) - abort(status, msg) + return (msg, status) def send_webmentions(activity_wrapped, proxy=None, **response_props): @@ -244,7 +244,7 @@ def send_webmentions(activity_wrapped, proxy=None, **response_props): if errors: msg = 'Errors:\n' + '\n'.join(str(e) for e in errors) - return error(msg, status=errors[0].get('http_status')) + return error(msg, status=getattr(errors[0], 'http_status', None)) def postprocess_as2(activity, target=None, key=None): @@ -418,9 +418,9 @@ def redirect_unwrap(val): elif isinstance(val, str): prefix = urllib.parse.urljoin(request.host_url, '/r/') if val.startswith(prefix): - return val[len(prefix):] + return util.follow_redirects(val[len(prefix):]).url elif val.startswith(request.host_url): - return util.follow_redirects( - util.domain_from_link(urllib.parse.urlparse(val).path.strip('/'))).url + domain = util.domain_from_link(urllib.parse.urlparse(val).path.strip('/')) + return util.follow_redirects(domain).url return val diff --git a/redirect.py b/redirect.py index 0a54f90..abc0271 100644 --- a/redirect.py +++ b/redirect.py @@ -43,7 +43,7 @@ def redir(to=None): to = re.sub(r'^(https?:/)([^/])', r'\1/\2', to) if not to.startswith('http://') and not to.startswith('https://'): - error(f'Expected fully qualified URL; got {to}') + return error(f'Expected fully qualified URL; got {to}') # check that we've seen this domain before so we're not an open redirect domains = set((util.domain_from_link(to), diff --git a/salmon.py b/salmon.py index 7178663..954a0c8 100644 --- a/salmon.py +++ b/salmon.py @@ -76,7 +76,7 @@ def slap(acct): # # updated = utils.parse_updated_from_atom(data) # if not utils.verify_timestamp(updated): - # self.error('Timestamp is more than 1h old.') + # return error('Timestamp is more than 1h old.') # send webmentions to each target activity = atom.atom_to_activity(data) diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 69bf99f..fdf1a0c 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -12,12 +12,11 @@ from oauth_dropins.webutil.util import json_dumps, json_loads import requests import activitypub -from app import application +from app import app, cache import common from models import Follower, MagicKey, Response from . import testutil - REPLY_OBJECT = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Note', @@ -28,7 +27,7 @@ REPLY_OBJECT = { 'cc': ['https://www.w3.org/ns/activitystreams#Public'], } REPLY_OBJECT_WRAPPED = copy.deepcopy(REPLY_OBJECT) -REPLY_OBJECT_WRAPPED['inReplyTo'] = 'http://localhost:80/r/orig/post' +REPLY_OBJECT_WRAPPED['inReplyTo'] = 'http://localhost/r/orig/post' REPLY = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Create', @@ -113,7 +112,7 @@ FOLLOW_WRAPPED_WITH_ACTOR['actor'] = FOLLOW_WITH_ACTOR['actor'] ACCEPT = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Accept', - 'id': 'tag:localhost:80:accept/realize.be/https://mastodon.social/6d1a', + 'id': 'tag:localhost:accept/realize.be/https://mastodon.social/6d1a', 'actor': 'http://localhost/realize.be', 'object': { 'type': 'Follow', @@ -138,6 +137,8 @@ DELETE = { 'object': 'https://mastodon.social/users/swentel', } +client = app.test_client() + @patch('requests.post') @patch('requests.get') @@ -146,19 +147,20 @@ class ActivityPubTest(testutil.TestCase): def setUp(self): super(ActivityPubTest, self).setUp() - activitypub.ActorHandler.get.cache_clear() + app.testing = True + cache.clear() - def test_actor_handler(self, _, mock_get, __): + def test_actor(self, _, mock_get, __): mock_get.return_value = requests_response(""" Mrs. ☕ Foo """, url='https://foo.com/', content_type=common.CONTENT_TYPE_HTML) - got = application.get_response('/foo.com') + got = client.get('/foo.com') mock_get.assert_called_once_with('http://foo.com/', headers=common.HEADERS, stream=True, timeout=util.HTTP_TIMEOUT) - self.assertEqual(200, got.status_int) + self.assertEqual(200, got.status_code) type = got.headers['Content-Type'] self.assertTrue(type.startswith(common.CONTENT_TYPE_AS2), type) self.assertEqual({ @@ -180,9 +182,9 @@ class ActivityPubTest(testutil.TestCase): 'id': 'foo.com', 'publicKeyPem': MagicKey.get_by_id('foo.com').public_pem().decode(), }, - }, json_loads(got.body)) + }, got.json) - def test_actor_handler_no_hcard(self, _, mock_get, __): + def test_actor_no_hcard(self, _, mock_get, __): mock_get.return_value = requests_response("""
@@ -191,15 +193,15 @@ class ActivityPubTest(testutil.TestCase): """) - got = application.get_response('/foo.com') + got = client.get('/foo.com') mock_get.assert_called_once_with('http://foo.com/', headers=common.HEADERS, stream=True, timeout=util.HTTP_TIMEOUT) - self.assertEqual(400, got.status_int) - self.assertIn('representative h-card', got.body.decode()) + self.assertEqual(400, got.status_code) + self.assertIn('representative h-card', got.get_data(as_text=True)) def test_actor_blocked_tld(self, _, __, ___): - got = application.get_response('/foo.json') - self.assertEqual(404, got.status_int) + got = client.get('/foo.json') + self.assertEqual(404, got.status_code) def test_inbox_reply_object(self, *mocks): self._test_inbox_reply(REPLY_OBJECT, REPLY_OBJECT, *mocks) @@ -216,9 +218,8 @@ class ActivityPubTest(testutil.TestCase): '') mock_post.return_value = requests_response() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(as2).encode()) - self.assertEqual(200, got.status_int, got.body) + got = client.post('/foo.com/inbox', json=as2) + self.assertEqual(200, got.status_code, got.get_data(as_text=True)) mock_get.assert_called_once_with( 'http://orig/post', headers=common.HEADERS, timeout=15, stream=True) @@ -246,9 +247,8 @@ class ActivityPubTest(testutil.TestCase): mock_head.return_value = requests_response(url='http://this/') - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(reply).encode()) - self.assertEqual(200, got.status_int, got.body) + got = client.post('/foo.com/inbox', json=reply) + self.assertEqual(200, got.status_code, got.get_data(as_text=True)) mock_head.assert_called_once_with( 'http://this', allow_redirects=True, stream=True, timeout=15) @@ -268,28 +268,28 @@ class ActivityPubTest(testutil.TestCase): '') mock_post.return_value = requests_response() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(as2).encode()) - self.assertEqual(200, got.status_int, got.body) - mock_get.assert_called_once_with( - 'http://target/', headers=common.HEADERS, timeout=15, stream=True) + with app.test_client() as test_client: + got = test_client.post('/foo.com/inbox', json=as2) + self.assertEqual(200, got.status_code, got.get_data(as_text=True)) + mock_get.assert_called_once_with( + 'http://target/', headers=common.HEADERS, timeout=15, stream=True) - expected_headers = copy.deepcopy(common.HEADERS) - expected_headers['Accept'] = '*/*' - mock_post.assert_called_once_with( - 'http://target/webmention', - data={ - 'source': 'http://localhost/render?source=http%3A%2F%2Fthis%2Fmention&target=http%3A%2F%2Ftarget%2F', - 'target': 'http://target/', - }, - allow_redirects=False, timeout=15, stream=True, - headers=expected_headers) + expected_headers = copy.deepcopy(common.HEADERS) + expected_headers['Accept'] = '*/*' + mock_post.assert_called_once_with( + 'http://target/webmention', + data={ + 'source': 'http://localhost/render?source=http%3A%2F%2Fthis%2Fmention&target=http%3A%2F%2Ftarget%2F', + 'target': 'http://target/', + }, + allow_redirects=False, timeout=15, stream=True, + headers=expected_headers) - resp = Response.get_by_id('http://this/mention http://target/') - self.assertEqual('in', resp.direction) - self.assertEqual('activitypub', resp.protocol) - self.assertEqual('complete', resp.status) - self.assertEqual(self.handler.redirect_unwrap(as2), json_loads(resp.source_as2)) + resp = Response.get_by_id('http://this/mention http://target/') + self.assertEqual('in', resp.direction) + self.assertEqual('activitypub', resp.protocol) + self.assertEqual('complete', resp.status) + self.assertEqual(common.redirect_unwrap(as2), json_loads(resp.source_as2)) def test_inbox_like(self, mock_head, mock_get, mock_post): mock_head.return_value = requests_response(url='http://orig/post') @@ -302,9 +302,8 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(LIKE_WRAPPED).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/foo.com/inbox', json=LIKE) + self.assertEqual(200, got.status_code) as2_headers = copy.deepcopy(common.HEADERS) as2_headers.update(common.CONNEG_HEADERS_AS2_HTML) @@ -339,9 +338,8 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(FOLLOW_WRAPPED).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/foo.com/inbox', json=FOLLOW_WRAPPED) + self.assertEqual(200, got.status_code) as2_headers = copy.deepcopy(common.HEADERS) as2_headers.update(common.CONNEG_HEADERS_AS2_HTML) @@ -379,9 +377,8 @@ class ActivityPubTest(testutil.TestCase): Follower(id=Follower._id('realize.be', FOLLOW['actor'])).put() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(UNDO_FOLLOW_WRAPPED).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + self.assertEqual(200, got.status_code) follower = Follower.get_by_id('realize.be %s' % FOLLOW['actor']) self.assertEqual('inactive', follower.status) @@ -389,28 +386,26 @@ class ActivityPubTest(testutil.TestCase): def test_inbox_undo_follow_doesnt_exist(self, mock_head, mock_get, mock_post): mock_head.return_value = requests_response(url='https://realize.be/') - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(UNDO_FOLLOW_WRAPPED).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + self.assertEqual(200, got.status_code) def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post): mock_head.return_value = requests_response(url='https://realize.be/') Follower(id=Follower._id('realize.be', 'https://mastodon.social/users/swentel'), status='inactive').put() - got = application.get_response('/foo.com/inbox', method='POST', - body=json_dumps(UNDO_FOLLOW_WRAPPED).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + self.assertEqual(200, got.status_code) def test_inbox_unsupported_type(self, *_): - got = application.get_response('/foo.com/inbox', method='POST', body=json_dumps({ + got = client.post('/foo.com/inbox', json={ '@context': ['https://www.w3.org/ns/activitystreams'], 'id': 'https://xoxo.zone/users/aaronpk#follows/40', 'type': 'Block', 'actor': 'https://xoxo.zone/users/aaronpk', 'object': 'http://snarfed.org/', - }).encode()) - self.assertEqual(501, got.status_int) + }) + self.assertEqual(501, got.status_code) def test_inbox_delete_actor(self, mock_head, mock_get, mock_post): follower = Follower.get_or_create('realize.be', DELETE['actor']) @@ -419,9 +414,8 @@ class ActivityPubTest(testutil.TestCase): other = Follower.get_or_create('realize.be', 'https://mas.to/users/other') self.assertEqual(3, Follower.query().count()) - got = application.get_response('/realize.be/inbox', method='POST', - body=json_dumps(DELETE).encode()) - self.assertEqual(200, got.status_int) + got = client.post('/realize.be/inbox', json=DELETE) + self.assertEqual(200, got.status_code) # TODO: bring back # self.assertEqual([other], Follower.query().fetch())