From 6aa6f1d8f869e3821a40dd7a2b061bfa098eb008 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Tue, 3 Apr 2018 19:48:50 +0200
Subject: [PATCH] Test actor can now follow back

---
 api/funkwhale_api/federation/activity.py    | 34 +++++++++
 api/funkwhale_api/federation/actors.py      | 81 ++++++++++++++++-----
 api/funkwhale_api/federation/factories.py   | 17 +++++
 api/funkwhale_api/federation/serializers.py |  4 +-
 api/tests/federation/test_actors.py         | 77 +++++++++++++++++++-
 5 files changed, 192 insertions(+), 21 deletions(-)

diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 4eeb193b..3b7648f1 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -83,3 +83,37 @@ def deliver(activity, on_behalf_of, to=[]):
         )
         response.raise_for_status()
         logger.debug('Remote answered with %s', response.status_code)
+
+
+def get_follow(follow_id, follower, followed):
+    return {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {}
+        ],
+        'actor': follower.url,
+        'id': follower.url + '#follows/{}'.format(follow_id),
+        'object': followed.url,
+        'type': 'Follow'
+    }
+
+
+def get_accept_follow(accept_id, accept_actor, follow, follow_actor):
+    return {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://w3id.org/security/v1",
+            {}
+        ],
+        "id": accept_actor.url + '#accepts/follows/{}'.format(
+            accept_id),
+        "type": "Accept",
+        "actor": accept_actor.url,
+        "object": {
+            "id": follow['id'],
+            "type": "Follow",
+            "actor": follow_actor.url,
+            "object": accept_actor.url
+        },
+    }
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index e29125f5..031526f8 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -1,5 +1,6 @@
 import logging
 import requests
+import uuid
 import xml
 
 from django.conf import settings
@@ -98,7 +99,7 @@ class SystemActor(object):
         raise NotImplementedError
 
     def post_inbox(self, data, actor=None):
-        raise NotImplementedError
+        return self.handle(data, actor=actor)
 
     def get_outbox(self, data, actor=None):
         raise NotImplementedError
@@ -106,6 +107,31 @@ class SystemActor(object):
     def post_outbox(self, data, actor=None):
         raise NotImplementedError
 
+    def handle(self, data, actor=None):
+        """
+        Main entrypoint for handling activities posted to the
+        actor's inbox
+        """
+        logger.info('Received activity on %s inbox', self.id)
+
+        if actor is None:
+            raise PermissionDenied('Actor not authenticated')
+
+        serializer = serializers.ActivitySerializer(
+            data=data, context={'actor': actor})
+        serializer.is_valid(raise_exception=True)
+
+        ac = serializer.data
+        try:
+            handler = getattr(
+                self, 'handle_{}'.format(ac['type'].lower()))
+        except (KeyError, AttributeError):
+            logger.debug(
+                'No handler for activity %s', ac['type'])
+            return
+
+        return handler(ac, actor)
+
 
 class LibraryActor(SystemActor):
     id = 'library'
@@ -147,23 +173,6 @@ class TestActor(SystemActor):
         	"orderedItems": []
         }
 
-    def post_inbox(self, data, actor=None):
-        if actor is None:
-            raise PermissionDenied('Actor not authenticated')
-
-        serializer = serializers.ActivitySerializer(
-            data=data, context={'actor': actor})
-        serializer.is_valid(raise_exception=True)
-
-        ac = serializer.validated_data
-        logger.info('Received activity on %s inbox', self.id)
-        if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
-            # we received a toot \o/
-            command = self.parse_command(ac['object']['content'])
-            logger.debug('Parsed command: %s', command)
-            if command == 'ping':
-                self.handle_ping(ac, actor)
-
     def parse_command(self, message):
         """
         Remove any links or fancy markup to extract /command from
@@ -175,7 +184,16 @@ class TestActor(SystemActor):
         except IndexError:
             return
 
-    def handle_ping(self, ac, sender):
+    def handle_create(self, ac, sender):
+        if ac['object']['type'] != 'Note':
+            return
+
+        # we received a toot \o/
+        command = self.parse_command(ac['object']['content'])
+        logger.debug('Parsed command: %s', command)
+        if command != 'ping':
+            return
+
         now = timezone.now()
         test_actor = self.get_actor_instance()
         reply_url = 'https://{}/activities/note/{}'.format(
@@ -221,6 +239,31 @@ class TestActor(SystemActor):
             to=[ac['actor']],
             on_behalf_of=test_actor)
 
+    def handle_follow(self, ac, sender):
+        # on a follow we:
+        # 1. send the accept answer
+        # 2. follow back
+        test_actor = self.get_actor_instance()
+        accept_uuid = uuid.uuid4()
+        accept = activity.get_accept_follow(
+            accept_id=accept_uuid,
+            accept_actor=test_actor,
+            follow=ac,
+            follow_actor=sender)
+        activity.deliver(
+            accept,
+            to=[ac['actor']],
+            on_behalf_of=test_actor)
+        follow_uuid = uuid.uuid4()
+        follow = activity.get_follow(
+            follow_id=follow_uuid,
+            follower=test_actor,
+            followed=sender)
+        activity.deliver(
+            follow,
+            to=[ac['actor']],
+            on_behalf_of=test_actor)
+
 SYSTEM_ACTORS = {
     'library': LibraryActor(),
     'test': TestActor(),
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 88c86f79..6e621c7f 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -89,3 +89,20 @@ class NoteFactory(factory.Factory):
 
     class Meta:
         model = dict
+
+
+@registry.register(name='federation.Activity')
+class ActivityFactory(factory.Factory):
+    type = 'Create'
+    id = factory.Faker('url')
+    published = factory.LazyFunction(
+        lambda: timezone.now().isoformat()
+    )
+    actor = factory.Faker('url')
+    object = factory.SubFactory(
+        NoteFactory,
+        actor=factory.SelfAttribute('..actor'),
+        published=factory.SelfAttribute('..published'))
+
+    class Meta:
+        model = dict
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 2137e8d9..7c35aead 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -120,7 +120,9 @@ class ActivitySerializer(serializers.Serializer):
             type = value['type']
         except KeyError:
             raise serializers.ValidationError('Missing object type')
-
+        except TypeError:
+            # probably a URL
+            return value
         try:
             object_serializer = OBJECT_SERIALIZERS[type]
         except KeyError:
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
index e72232fc..f0d8f784 100644
--- a/api/tests/federation/test_actors.py
+++ b/api/tests/federation/test_actors.py
@@ -1,4 +1,5 @@
 import pytest
+import uuid
 
 from django.urls import reverse
 from django.utils import timezone
@@ -127,7 +128,7 @@ def test_test_post_outbox_validates_actor(nodb_factories):
         assert msg in exc_info.value
 
 
-def test_test_post_outbox_handles_create_note(
+def test_test_post_inbox_handles_create_note(
         settings, mocker, factories):
     deliver = mocker.patch(
         'funkwhale_api.federation.activity.deliver')
@@ -238,3 +239,77 @@ def test_library_actor_manually_approves_based_on_setting(
     settings.FEDERATION_MUSIC_NEEDS_APPROVAL = value
     library_conf = actors.SYSTEM_ACTORS['library']
     assert library_conf.manually_approves_followers is value
+
+
+def test_system_actor_handle(mocker, nodb_factories):
+    handler = mocker.patch(
+        'funkwhale_api.federation.actors.TestActor.handle_create')
+    actor = nodb_factories['federation.Actor']()
+    activity = nodb_factories['federation.Activity'](
+        type='Create', actor=actor.url)
+    serializer = serializers.ActivitySerializer(
+        data=activity
+    )
+    assert serializer.is_valid()
+    actors.SYSTEM_ACTORS['test'].handle(activity, actor)
+    handler.assert_called_once_with(serializer.data, actor)
+
+
+def test_test_actor_handles_follow(
+        settings, mocker, factories):
+    deliver = mocker.patch(
+        'funkwhale_api.federation.activity.deliver')
+    actor = factories['federation.Actor']()
+    now = timezone.now()
+    mocker.patch('django.utils.timezone.now', return_value=now)
+    test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
+    data = {
+        'actor': actor.url,
+        'type': 'Follow',
+        'id': 'http://test.federation/user#follows/267',
+        'object': test_actor.url,
+    }
+    uid = uuid.uuid4()
+    mocker.patch('uuid.uuid4', return_value=uid)
+    expected_accept = {
+    	"@context": [
+    		"https://www.w3.org/ns/activitystreams",
+    		"https://w3id.org/security/v1",
+    		{}
+    	],
+    	"id": test_actor.url + '#accepts/follows/{}'.format(uid),
+    	"type": "Accept",
+    	"actor": test_actor.url,
+    	"object": {
+    		"id": data['id'],
+    		"type": "Follow",
+    		"actor": actor.url,
+    		"object": test_actor.url
+    	},
+    }
+    expected_follow = {
+        '@context': [
+            'https://www.w3.org/ns/activitystreams',
+            'https://w3id.org/security/v1',
+            {}
+        ],
+        'actor': test_actor.url,
+        'id': test_actor.url + '#follows/{}'.format(uid),
+        'object': actor.url,
+        'type': 'Follow'
+    }
+
+    actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
+    expected_calls = [
+        mocker.call(
+            expected_accept,
+            to=[actor.url],
+            on_behalf_of=test_actor,
+        ),
+        mocker.call(
+            expected_follow,
+            to=[actor.url],
+            on_behalf_of=test_actor,
+        )
+    ]
+    deliver.assert_has_calls(expected_calls)
-- 
GitLab