diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 24a1f782e..af31b8c5a 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -6,8 +6,10 @@ import uuid from django.conf import settings from funkwhale_api.common import session +from funkwhale_api.common import utils as funkwhale_utils from . import models +from . import serializers from . import signing logger = logging.getLogger(__name__) @@ -85,66 +87,9 @@ def deliver(activity, on_behalf_of, to=[]): 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_undo(id, actor, object): - return { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - {} - ], - 'type': 'Undo', - 'id': id + '/undo', - 'actor': actor.url, - 'object': object, - } - - -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 - }, - } - - -def accept_follow(target, follow, actor): - accept_uuid = uuid.uuid4() - accept = get_accept_follow( - accept_id=accept_uuid, - accept_actor=target, - follow=follow, - follow_actor=actor) +def accept_follow(follow): + serializer = serializers.AcceptFollowSerializer(follow) deliver( - accept, - to=[actor.url], - on_behalf_of=target) - return models.Follow.objects.get_or_create( - actor=actor, - target=target, - ) + serializer.data, + to=[follow.actor.url], + on_behalf_of=follow.target) diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py index bb0b99cc2..5a4e917bd 100644 --- a/api/funkwhale_api/federation/actors.py +++ b/api/funkwhale_api/federation/actors.py @@ -153,24 +153,32 @@ class SystemActor(object): def handle_follow(self, ac, sender): system_actor = self.get_actor_instance() - if self.manually_approves_followers: - fr, created = models.FollowRequest.objects.get_or_create( - actor=sender, - target=system_actor, - approved=None, - ) - return fr + serializer = serializers.FollowSerializer( + data=ac, context={'follow_actor': sender}) + if not serializer.is_valid(): + return logger.info('Invalid follow payload') + approved = True if not self.manually_approves_followers else None + follow = serializer.save(approved=approved) + if follow.approved: + return activity.accept_follow(follow) - return activity.accept_follow( - system_actor, ac, sender - ) + def handle_accept(self, ac, sender): + system_actor = self.get_actor_instance() + serializer = serializers.AcceptFollowSerializer( + data=ac, + context={'follow_target': sender, 'follow_actor': system_actor}) + if not serializer.is_valid(raise_exception=True): + return logger.info('Received invalid payload') + + serializer.save() def handle_undo_follow(self, ac, sender): - actor = self.get_actor_instance() - models.Follow.objects.filter( - actor=sender, - target=actor, - ).delete() + system_actor = self.get_actor_instance() + serializer = serializers.UndoFollowSerializer( + data=ac, context={'actor': sender, 'target': system_actor}) + if not serializer.is_valid(): + return logger.info('Received invalid payload') + serializer.save() def handle_undo(self, ac, sender): if ac['object']['type'] != 'Follow': @@ -206,20 +214,6 @@ class LibraryActor(SystemActor): def manually_approves_followers(self): return settings.FEDERATION_MUSIC_NEEDS_APPROVAL - def handle_follow(self, ac, sender): - system_actor = self.get_actor_instance() - if self.manually_approves_followers: - fr, created = models.FollowRequest.objects.get_or_create( - actor=sender, - target=system_actor, - approved=None, - ) - return fr - - return activity.accept_follow( - system_actor, ac, sender - ) - @transaction.atomic def handle_create(self, ac, sender): try: @@ -360,15 +354,15 @@ class TestActor(SystemActor): super().handle_follow(ac, sender) # also, we follow back test_actor = self.get_actor_instance() - follow_uuid = uuid.uuid4() - follow = activity.get_follow( - follow_id=follow_uuid, - follower=test_actor, - followed=sender) + follow_back = models.Follow.objects.get_or_create( + actor=test_actor, + target=sender, + approved=None, + )[0] activity.deliver( - follow, - to=[ac['actor']], - on_behalf_of=test_actor) + serializers.FollowSerializer(follow_back).data, + to=[follow_back.target.url], + on_behalf_of=follow_back.actor) def handle_undo_follow(self, ac, sender): super().handle_undo_follow(ac, sender) @@ -381,11 +375,7 @@ class TestActor(SystemActor): ) except models.Follow.DoesNotExist: return - undo = activity.get_undo( - id=follow.get_federation_url(), - actor=actor, - object=serializers.FollowSerializer(follow).data, - ) + undo = serializers.UndoFollowSerializer(follow).data follow.delete() activity.deliver( undo, diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py index ce636299f..6a097174b 100644 --- a/api/funkwhale_api/federation/admin.py +++ b/api/funkwhale_api/federation/admin.py @@ -23,24 +23,13 @@ class FollowAdmin(admin.ModelAdmin): list_display = [ 'actor', 'target', + 'approved', 'creation_date' ] - search_fields = ['actor__url', 'target__url'] - list_select_related = True - - -@admin.register(models.FollowRequest) -class FollowRequestAdmin(admin.ModelAdmin): - list_display = [ - 'actor', - 'target', - 'creation_date', - 'approved' - ] - search_fields = ['actor__url', 'target__url'] list_filter = [ 'approved' ] + search_fields = ['actor__url', 'target__url'] list_select_related = True diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index b3ac72039..1aeb733c8 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -113,15 +113,6 @@ class FollowFactory(factory.DjangoModelFactory): ) -@registry.register -class FollowRequestFactory(factory.DjangoModelFactory): - target = factory.SubFactory(ActorFactory) - actor = factory.SubFactory(ActorFactory) - - class Meta: - model = models.FollowRequest - - @registry.register class LibraryFactory(factory.DjangoModelFactory): actor = factory.SubFactory(ActorFactory) diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py index f19a7a291..177f14754 100644 --- a/api/funkwhale_api/federation/library.py +++ b/api/funkwhale_api/federation/library.py @@ -38,25 +38,21 @@ def scan_from_account_name(account_name): actor__domain=domain, actor__preferred_username=username ).select_related('actor').first() - follow_request = None - if library: - data['local']['following'] = True - data['local']['awaiting_approval'] = True - - else: - follow_request = models.FollowRequest.objects.filter( + data['local'] = { + 'following': False, + 'awaiting_approval': False, + } + try: + follow = models.Follow.objects.get( target__preferred_username=username, target__domain=username, actor=system_library, - ).first() - data['local'] = { - 'following': False, - 'awaiting_approval': False, - } - if follow_request: - data['awaiting_approval'] = follow_request.approved is None + ) + data['local']['awaiting_approval'] = not bool(follow.approved) + data['local']['following'] = True + except models.Follow.DoesNotExist: + pass - follow_request = models.Follow try: data['webfinger'] = webfinger.get_resource( 'acct:{}'.format(account_name)) diff --git a/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py b/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py new file mode 100644 index 000000000..b199706aa --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0004_auto_20180410_1624.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.3 on 2018-04-10 16:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0003_auto_20180407_1010'), + ] + + operations = [ + migrations.RemoveField( + model_name='followrequest', + name='actor', + ), + migrations.RemoveField( + model_name='followrequest', + name='target', + ), + migrations.AddField( + model_name='follow', + name='approved', + field=models.NullBooleanField(default=None), + ), + migrations.DeleteModel( + name='FollowRequest', + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index bf1e5d830..201463066 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -109,6 +109,7 @@ class Follow(models.Model): creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField( auto_now=True) + approved = models.NullBooleanField(default=None) class Meta: unique_together = ['actor', 'target'] @@ -117,49 +118,6 @@ class Follow(models.Model): return '{}#follows/{}'.format(self.actor.url, self.uuid) -class FollowRequest(models.Model): - uuid = models.UUIDField(default=uuid.uuid4, unique=True) - actor = models.ForeignKey( - Actor, - related_name='emmited_follow_requests', - on_delete=models.CASCADE, - ) - target = models.ForeignKey( - Actor, - related_name='received_follow_requests', - on_delete=models.CASCADE, - ) - creation_date = models.DateTimeField(default=timezone.now) - modification_date = models.DateTimeField( - auto_now=True) - approved = models.NullBooleanField(default=None) - - def approve(self): - from . import activity - from . import serializers - self.approved = True - self.save(update_fields=['approved']) - Follow.objects.get_or_create( - target=self.target, - actor=self.actor - ) - if self.target.is_local: - follow = { - '@context': serializers.AP_CONTEXT, - 'actor': self.actor.url, - 'id': self.actor.url + '#follows/{}'.format(uuid.uuid4()), - 'object': self.target.url, - 'type': 'Follow' - } - activity.accept_follow( - self.target, follow, self.actor - ) - - def refuse(self): - self.approved = False - self.save(update_fields=['approved']) - - class Library(models.Model): creation_date = models.DateTimeField(default=timezone.now) modification_date = models.DateTimeField( diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 704ad6364..f0d1e35fd 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -121,28 +121,132 @@ class LibraryActorSerializer(ActorSerializer): return validated_data -class FollowSerializer(serializers.ModelSerializer): - # left maps to activitypub fields, right to our internal models - id = serializers.URLField(source='get_federation_url') - object = serializers.URLField(source='target.url') - actor = serializers.URLField(source='actor.url') - type = serializers.CharField(source='ap_type') +class FollowSerializer(serializers.Serializer): + id = serializers.URLField() + object = serializers.URLField() + actor = serializers.URLField() + type = serializers.ChoiceField(choices=['Follow']) - class Meta: - model = models.Actor - fields = [ - 'id', - 'object', - 'actor', - 'type' - ] + def validate_object(self, v): + expected = self.context.get('follow_target') + if expected and expected.url != v: + raise serializers.ValidationError('Invalid target') + try: + return models.Actor.objects.get(url=v) + except models.Actor.DoesNotExist: + raise serializers.ValidationError('Target not found') + + def validate_actor(self, v): + expected = self.context.get('follow_actor') + if expected and expected.url != v: + raise serializers.ValidationError('Invalid actor') + try: + return models.Actor.objects.get(url=v) + except models.Actor.DoesNotExist: + raise serializers.ValidationError('Actor not found') + + def save(self, **kwargs): + return models.Follow.objects.get_or_create( + actor=self.validated_data['actor'], + target=self.validated_data['object'], + **kwargs, + )[0] def to_representation(self, instance): - ret = super().to_representation(instance) - ret['@context'] = AP_CONTEXT + return { + '@context': AP_CONTEXT, + 'actor': instance.actor.url, + 'id': instance.get_federation_url(), + 'object': instance.target.url, + 'type': 'Follow' + } return ret +class AcceptFollowSerializer(serializers.Serializer): + id = serializers.URLField() + actor = serializers.URLField() + object = FollowSerializer() + type = serializers.ChoiceField(choices=['Accept']) + + def validate_actor(self, v): + expected = self.context.get('follow_target') + if expected and expected.url != v: + raise serializers.ValidationError('Invalid actor') + try: + return models.Actor.objects.get(url=v) + except models.Actor.DoesNotExist: + raise serializers.ValidationError('Actor not found') + + def validate(self, validated_data): + # we ensure the accept actor actually match the follow target + if validated_data['actor'] != validated_data['object']['object']: + raise serializers.ValidationError('Actor mismatch') + try: + validated_data['follow'] = models.Follow.objects.filter( + target=validated_data['actor'], + actor=validated_data['object']['actor'] + ).exclude(approved=True).get() + except models.Follow.DoesNotExist: + raise serializers.ValidationError('No follow to accept') + return validated_data + + def to_representation(self, instance): + return { + "@context": AP_CONTEXT, + "id": instance.get_federation_url() + '/accept', + "type": "Accept", + "actor": instance.target.url, + "object": FollowSerializer(instance).data + } + + def save(self): + self.validated_data['follow'].approved = True + self.validated_data['follow'].save() + return self.validated_data['follow'] + + +class UndoFollowSerializer(serializers.Serializer): + id = serializers.URLField() + actor = serializers.URLField() + object = FollowSerializer() + type = serializers.ChoiceField(choices=['Undo']) + + def validate_actor(self, v): + expected = self.context.get('follow_target') + if expected and expected.url != v: + raise serializers.ValidationError('Invalid actor') + try: + return models.Actor.objects.get(url=v) + except models.Actor.DoesNotExist: + raise serializers.ValidationError('Actor not found') + + def validate(self, validated_data): + # we ensure the accept actor actually match the follow actor + if validated_data['actor'] != validated_data['object']['actor']: + raise serializers.ValidationError('Actor mismatch') + try: + validated_data['follow'] = models.Follow.objects.filter( + actor=validated_data['actor'], + target=validated_data['object']['object'] + ).get() + except models.Follow.DoesNotExist: + raise serializers.ValidationError('No follow to remove') + return validated_data + + def to_representation(self, instance): + return { + "@context": AP_CONTEXT, + "id": instance.get_federation_url() + '/undo', + "type": "Undo", + "actor": instance.actor.url, + "object": FollowSerializer(instance).data + } + + def save(self): + self.validated_data['follow'].delete() + + class ActorWebfingerSerializer(serializers.Serializer): subject = serializers.CharField() aliases = serializers.ListField(child=serializers.URLField()) diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index aaab343e4..2d4220472 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,6 +1,7 @@ from django import forms from django.conf import settings from django.core import paginator +from django.db import transaction from django.http import HttpResponse from django.urls import reverse @@ -9,9 +10,12 @@ from rest_framework import response from rest_framework import views from rest_framework import viewsets from rest_framework.decorators import list_route, detail_route +from rest_framework.serializers import ValidationError +from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.music.models import TrackFile +from . import activity from . import actors from . import authentication from . import library @@ -172,3 +176,29 @@ class LibraryViewSet(viewsets.GenericViewSet): data = library.scan_from_account_name(account) return response.Response(data) + + @transaction.atomic + def create(self, request, *args, **kwargs): + try: + actor_url = request.data['actor_url'] + except KeyError: + raise ValidationError('Missing actor_url') + + try: + actor = actors.get_actor(actor_url) + library_data = library.get_library_data(actor.url) + except Exception as e: + raise ValidationError('Error while fetching actor and library') + + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + follow, created = models.Follow.objects.get_or_create( + actor=library_actor, + target=actor, + ) + serializer = serializers.FollowSerializer(follow) + activity.deliver( + serializer.data, + on_behalf_of=library_actor, + to=[actor.url] + ) + return response.Response({}, status=201) diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index 09c5e3bf7..dbd60bbd7 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -1,6 +1,7 @@ import uuid from funkwhale_api.federation import activity +from funkwhale_api.federation import serializers def test_deliver(nodb_factories, r_mock, mocker): @@ -38,37 +39,9 @@ def test_deliver(nodb_factories, r_mock, mocker): def test_accept_follow(mocker, factories): deliver = mocker.patch( 'funkwhale_api.federation.activity.deliver') - actor = factories['federation.Actor']() - target = factories['federation.Actor'](local=True) - follow = { - 'actor': actor.url, - 'type': 'Follow', - 'id': 'http://test.federation/user#follows/267', - 'object': target.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": target.url + '#accepts/follows/{}'.format(uid), - "type": "Accept", - "actor": target.url, - "object": { - "id": follow['id'], - "type": "Follow", - "actor": actor.url, - "object": target.url - }, - } - activity.accept_follow( - target, follow, actor - ) + follow = factories['federation.Follow'](approved=None) + expected_accept = serializers.AcceptFollowSerializer(follow).data + activity.accept_follow(follow) deliver.assert_called_once_with( - expected_accept, to=[actor.url], on_behalf_of=target + expected_accept, to=[follow.actor.url], on_behalf_of=follow.target ) - follow_instance = actor.emitted_follows.first() - assert follow_instance.target == target diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py index 090d9b03f..fe70cc6e5 100644 --- a/api/tests/federation/test_actors.py +++ b/api/tests/federation/test_actors.py @@ -7,6 +7,7 @@ from django.utils import timezone from rest_framework import exceptions +from funkwhale_api.federation import activity from funkwhale_api.federation import actors from funkwhale_api.federation import models from funkwhale_api.federation import serializers @@ -261,8 +262,6 @@ def test_test_actor_handles_follow( deliver = mocker.patch( 'funkwhale_api.federation.activity.deliver') actor = factories['federation.Actor']() - now = timezone.now() - mocker.patch('django.utils.timezone.now', return_value=now) accept_follow = mocker.patch( 'funkwhale_api.federation.activity.accept_follow') test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance() @@ -272,28 +271,15 @@ def test_test_actor_handles_follow( 'id': 'http://test.federation/user#follows/267', 'object': test_actor.url, } - uid = uuid.uuid4() - mocker.patch('uuid.uuid4', return_value=uid) - expected_follow = { - '@context': serializers.AP_CONTEXT, - '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) - accept_follow.assert_called_once_with( - test_actor, data, actor + follow = models.Follow.objects.get(target=test_actor, approved=True) + follow_back = models.Follow.objects.get(actor=test_actor, approved=None) + accept_follow.assert_called_once_with(follow) + deliver.assert_called_once_with( + serializers.FollowSerializer(follow_back).data, + on_behalf_of=test_actor, + to=[actor.url] ) - expected_calls = [ - mocker.call( - expected_follow, - to=[actor.url], - on_behalf_of=test_actor, - ) - ] - deliver.assert_has_calls(expected_calls) def test_test_actor_handles_undo_follow( @@ -346,12 +332,10 @@ def test_library_actor_handles_follow_manual_approval( } library_actor.system_conf.post_inbox(data, actor=actor) - fr = library_actor.received_follow_requests.first() + follow = library_actor.received_follows.first() - assert library_actor.received_follow_requests.count() == 1 - assert fr.target == library_actor - assert fr.actor == actor - assert fr.approved is None + assert follow.actor == actor + assert follow.approved is None def test_library_actor_handles_follow_auto_approval( @@ -369,10 +353,27 @@ def test_library_actor_handles_follow_auto_approval( } library_actor.system_conf.post_inbox(data, actor=actor) - assert library_actor.received_follow_requests.count() == 0 - accept_follow.assert_called_once_with( - library_actor, data, actor + follow = library_actor.received_follows.first() + + assert follow.actor == actor + assert follow.approved is True + + +def test_library_actor_handles_accept( + mocker, factories): + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = factories['federation.Actor']() + pending_follow = factories['federation.Follow']( + actor=library_actor, + target=actor, + approved=None, ) + serializer = serializers.AcceptFollowSerializer(pending_follow) + library_actor.system_conf.post_inbox(serializer.data, actor=actor) + + pending_follow.refresh_from_db() + + assert pending_follow.approved is True def test_library_actor_handle_create_audio_no_library(mocker, factories): diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index b17b6eb65..ae158e659 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -35,50 +35,6 @@ def test_follow_federation_url(factories): assert follow.get_federation_url() == expected -def test_follow_request_approve(mocker, factories): - uid = uuid.uuid4() - mocker.patch('uuid.uuid4', return_value=uid) - accept_follow = mocker.patch( - 'funkwhale_api.federation.activity.accept_follow') - fr = factories['federation.FollowRequest'](target__local=True) - fr.approve() - - follow = { - '@context': serializers.AP_CONTEXT, - 'actor': fr.actor.url, - 'id': fr.actor.url + '#follows/{}'.format(uid), - 'object': fr.target.url, - 'type': 'Follow' - } - - assert fr.approved is True - assert list(fr.target.followers.all()) == [fr.actor] - accept_follow.assert_called_once_with( - fr.target, follow, fr.actor - ) - - -def test_follow_request_approve_non_local(mocker, factories): - uid = uuid.uuid4() - mocker.patch('uuid.uuid4', return_value=uid) - accept_follow = mocker.patch( - 'funkwhale_api.federation.activity.accept_follow') - fr = factories['federation.FollowRequest']() - fr.approve() - - assert fr.approved is True - assert list(fr.target.followers.all()) == [fr.actor] - accept_follow.assert_not_called() - - -def test_follow_request_refused(mocker, factories): - fr = factories['federation.FollowRequest']() - fr.refuse() - - assert fr.approved is False - assert fr.target.followers.count() == 0 - - def test_library_model_unique_per_actor(factories): library = factories['federation.Library']() with pytest.raises(db.IntegrityError): diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 7b7dda33c..e6eca0a42 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -1,4 +1,5 @@ import arrow +import pytest from django.urls import reverse from django.core.paginator import Paginator @@ -170,6 +171,184 @@ def test_follow_serializer_to_ap(factories): assert serializer.data == expected +def test_follow_serializer_save(factories): + actor = factories['federation.Actor']() + target = factories['federation.Actor']() + + data = expected = { + 'id': 'https://test.follow', + 'type': 'Follow', + 'actor': actor.url, + 'object': target.url, + } + serializer = serializers.FollowSerializer(data=data) + + assert serializer.is_valid(raise_exception=True) + + follow = serializer.save() + + assert follow.pk is not None + assert follow.actor == actor + assert follow.target == target + assert follow.approved is None + + +def test_follow_serializer_save_validates_on_context(factories): + actor = factories['federation.Actor']() + target = factories['federation.Actor']() + impostor = factories['federation.Actor']() + + data = expected = { + 'id': 'https://test.follow', + 'type': 'Follow', + 'actor': actor.url, + 'object': target.url, + } + serializer = serializers.FollowSerializer( + data=data, + context={'follow_actor': impostor, 'follow_target': impostor}) + + assert serializer.is_valid() is False + + assert 'actor' in serializer.errors + assert 'object' in serializer.errors + + +def test_accept_follow_serializer_representation(factories): + follow = factories['federation.Follow'](approved=None) + + expected = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/accept', + 'type': 'Accept', + 'actor': follow.target.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer(follow) + + assert serializer.data == expected + + +def test_accept_follow_serializer_save(factories): + follow = factories['federation.Follow'](approved=None) + + data = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/accept', + 'type': 'Accept', + 'actor': follow.target.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + follow.refresh_from_db() + + assert follow.approved is True + + +def test_accept_follow_serializer_validates_on_context(factories): + follow = factories['federation.Follow'](approved=None) + impostor = factories['federation.Actor']() + data = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/accept', + 'type': 'Accept', + 'actor': impostor.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.AcceptFollowSerializer( + data=data, + context={'follow_actor': impostor, 'follow_target': impostor}) + + assert serializer.is_valid() is False + assert 'actor' in serializer.errors['object'] + assert 'object' in serializer.errors['object'] + + +def test_undo_follow_serializer_representation(factories): + follow = factories['federation.Follow'](approved=True) + + expected = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/undo', + 'type': 'Undo', + 'actor': follow.actor.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer(follow) + + assert serializer.data == expected + + +def test_undo_follow_serializer_save(factories): + follow = factories['federation.Follow'](approved=True) + + data = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/undo', + 'type': 'Undo', + 'actor': follow.actor.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer(data=data) + assert serializer.is_valid(raise_exception=True) + serializer.save() + + with pytest.raises(models.Follow.DoesNotExist): + follow.refresh_from_db() + + +def test_undo_follow_serializer_validates_on_context(factories): + follow = factories['federation.Follow'](approved=True) + impostor = factories['federation.Actor']() + data = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + {}, + ], + 'id': follow.get_federation_url() + '/undo', + 'type': 'Undo', + 'actor': impostor.url, + 'object': serializers.FollowSerializer(follow).data, + } + + serializer = serializers.UndoFollowSerializer( + data=data, + context={'follow_actor': impostor, 'follow_target': impostor}) + + assert serializer.is_valid() is False + assert 'actor' in serializer.errors['object'] + assert 'object' in serializer.errors['object'] + + def test_paginated_collection_serializer(factories): tfs = factories['music.TrackFile'].create_batch(size=5) actor = factories['federation.Actor'](local=True) diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 0b58e20f1..bd174f721 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -4,6 +4,7 @@ from django.core.paginator import Paginator import pytest from funkwhale_api.federation import actors +from funkwhale_api.federation import models from funkwhale_api.federation import serializers from funkwhale_api.federation import utils from funkwhale_api.federation import webfinger @@ -179,3 +180,35 @@ def test_can_scan_library(superuser_api_client, mocker): assert response.status_code == 200 assert response.data == result scan.assert_called_once_with('test@test.library') + + +def test_follow_library_manually(superuser_api_client, mocker, factories): + library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() + actor = factories['federation.Actor'](manually_approves_followers=True) + follow = {'test': 'follow'} + deliver = mocker.patch( + 'funkwhale_api.federation.activity.deliver') + actor_get = mocker.patch( + 'funkwhale_api.federation.actors.get_actor', + return_value=actor) + library_get = mocker.patch( + 'funkwhale_api.federation.library.get_library_data', + return_value={}) + + url = reverse('api:v1:federation:libraries-list') + response = superuser_api_client.post( + url, {'actor_url': actor.url}) + + assert response.status_code == 201 + + follow = models.Follow.objects.get( + actor=library_actor, + target=actor, + approved=None, + ) + + deliver.assert_called_once_with( + serializers.FollowSerializer(follow).data, + on_behalf_of=library_actor, + to=[actor.url] + )