import pytest from django.core.paginator import Paginator from django.urls import reverse from funkwhale_api.common import utils from funkwhale_api.federation import ( actors, serializers, webfinger, utils as federation_utils, ) def test_authenticate_allows_anonymous_actor_fetch_when_allow_list_enabled( preferences, api_client ): preferences["moderation__allow_list_enabled"] = True actor = actors.get_service_actor() url = reverse( "federation:actors-detail", kwargs={"preferred_username": actor.preferred_username}, ) response = api_client.get(url) assert response.status_code == 200 def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled( preferences, api_client, factories ): preferences["moderation__allow_list_enabled"] = True library = factories["music.Library"]() url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid},) response = api_client.get(url) assert response.status_code == 403 def test_wellknown_webfinger_validates_resource(db, api_client, settings, mocker): clean = mocker.spy(webfinger, "clean_resource") url = reverse("federation:well-known-webfinger") response = api_client.get(url, data={"resource": "something"}) clean.assert_called_once_with("something") assert url == "/.well-known/webfinger" assert response.status_code == 400 assert response.data["errors"]["resource"] == ("Missing webfinger resource type") def test_wellknown_nodeinfo(db, preferences, api_client, settings): expected = { "links": [ { "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", "href": "{}{}".format( settings.FUNKWHALE_URL, reverse("api:v1:instance:nodeinfo-2.0") ), } ] } url = reverse("federation:well-known-nodeinfo") response = api_client.get(url, HTTP_ACCEPT="application/jrd+json") assert response.status_code == 200 assert response["Content-Type"] == "application/jrd+json" assert response.data == expected def test_local_actor_detail(factories, api_client): user = factories["users.User"](with_actor=True) url = reverse( "federation:actors-detail", kwargs={"preferred_username": user.actor.preferred_username}, ) serializer = serializers.ActorSerializer(user.actor) response = api_client.get(url) assert response.status_code == 200 assert response.data == serializer.data def test_service_actor_detail(factories, api_client): actor = actors.get_service_actor() url = reverse( "federation:actors-detail", kwargs={"preferred_username": actor.preferred_username}, ) serializer = serializers.ActorSerializer(actor) response = api_client.get(url) assert response.status_code == 200 assert response.data == serializer.data def test_local_actor_inbox_post_requires_auth(factories, api_client): user = factories["users.User"](with_actor=True) url = reverse( "federation:actors-inbox", kwargs={"preferred_username": user.actor.preferred_username}, ) response = api_client.post(url, {"hello": "world"}) assert response.status_code == 403 def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_actor): patched_receive = mocker.patch("funkwhale_api.federation.activity.receive") user = factories["users.User"](with_actor=True) url = reverse( "federation:actors-inbox", kwargs={"preferred_username": user.actor.preferred_username}, ) response = api_client.post(url, {"hello": "world"}, format="json") assert response.status_code == 200 patched_receive.assert_called_once_with( activity={"hello": "world"}, on_behalf_of=authenticated_actor, inbox_actor=user.actor, ) def test_local_actor_inbox_post_receive( factories, api_client, mocker, authenticated_actor ): payload = { "to": [ "https://test.server/federation/music/libraries/956af6c9-1eb9-4117-8d17-b15e7b34afeb/followers" ], "type": "Create", "actor": authenticated_actor.fid, "object": { "id": "https://test.server/federation/music/uploads/fe564a47-b1d4-4596-bf96-008ccf407672", "type": "Audio", }, "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}, ], } user = factories["users.User"](with_actor=True) url = reverse( "federation:actors-inbox", kwargs={"preferred_username": user.actor.preferred_username}, ) response = api_client.post(url, payload, format="json") assert response.status_code == 200 def test_shared_inbox_post(factories, api_client, mocker, authenticated_actor): patched_receive = mocker.patch("funkwhale_api.federation.activity.receive") url = reverse("federation:shared-inbox") response = api_client.post(url, {"hello": "world"}, format="json") assert response.status_code == 200 patched_receive.assert_called_once_with( activity={"hello": "world"}, on_behalf_of=authenticated_actor ) def test_wellknown_webfinger_local(factories, api_client, settings, mocker): user = factories["users.User"](with_actor=True) url = reverse("federation:well-known-webfinger") response = api_client.get( url, data={"resource": "acct:{}".format(user.actor.webfinger_subject)}, HTTP_ACCEPT="application/jrd+json", ) serializer = serializers.ActorWebfingerSerializer(user.actor) assert response.status_code == 200 assert response["Content-Type"] == "application/jrd+json" assert response.data == serializer.data @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"]) def test_music_library_retrieve(factories, api_client, privacy_level): library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) expected = serializers.LibrarySerializer(library).data url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url) assert response.status_code == 200 assert response.data == expected def test_music_library_retrieve_excludes_channel_libraries(factories, api_client): channel = factories["audio.Channel"](local=True) library = channel.library url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url) assert response.status_code == 404 def test_actor_retrieve_excludes_channel_with_private_library(factories, api_client): channel = factories["audio.Channel"](external=True, library__privacy_level="me") url = reverse( "federation:actors-detail", kwargs={"preferred_username": channel.actor.preferred_username}, ) response = api_client.get(url) assert response.status_code == 404 def test_music_library_retrieve_page_public(factories, api_client): library = factories["music.Library"](privacy_level="everyone", actor__local=True) upload = factories["music.Upload"](library=library, import_status="finished") id = library.get_federation_id() expected = serializers.CollectionPageSerializer( { "id": id, "item_serializer": serializers.UploadSerializer, "actor": library.actor, "page": Paginator([upload], 1).page(1), "name": library.name, "summary": library.description, } ).data url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, {"page": 1}) assert response.status_code == 200 assert response.data == expected def test_channel_outbox_retrieve(factories, api_client): channel = factories["audio.Channel"](actor__local=True) expected = serializers.ChannelOutboxSerializer(channel).data url = reverse( "federation:actors-outbox", kwargs={"preferred_username": channel.actor.preferred_username}, ) response = api_client.get(url) assert response.status_code == 200 assert response.data == expected def test_channel_outbox_retrieve_page(factories, api_client): channel = factories["audio.Channel"](actor__local=True) upload = factories["music.Upload"](library=channel.library, playable=True) url = reverse( "federation:actors-outbox", kwargs={"preferred_username": channel.actor.preferred_username}, ) expected = serializers.CollectionPageSerializer( { "id": channel.actor.outbox_url, "item_serializer": serializers.ChannelCreateUploadSerializer, "actor": channel.actor, "page": Paginator([upload], 1).page(1), } ).data response = api_client.get(url, {"page": 1}) assert response.status_code == 200 assert response.data == expected def test_channel_upload_retrieve(factories, api_client): channel = factories["audio.Channel"](local=True) upload = factories["music.Upload"](library=channel.library, playable=True) url = reverse("federation:music:uploads-detail", kwargs={"uuid": upload.uuid}) expected = serializers.ChannelUploadSerializer(upload).data response = api_client.get(url) assert response.status_code == 200 assert response.data == expected def test_channel_upload_retrieve_activity(factories, api_client): channel = factories["audio.Channel"](local=True) upload = factories["music.Upload"](library=channel.library, playable=True) url = reverse("federation:music:uploads-activity", kwargs={"uuid": upload.uuid}) expected = serializers.ChannelCreateUploadSerializer(upload).data response = api_client.get(url) assert response.status_code == 200 assert response.data == expected @pytest.mark.parametrize("privacy_level", ["me", "instance"]) def test_music_library_retrieve_page_private(factories, api_client, privacy_level): library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, {"page": 1}) assert response.status_code == 403 @pytest.mark.parametrize("approved,expected", [(True, 200), (False, 403)]) def test_music_library_retrieve_page_follow( factories, api_client, authenticated_actor, approved, expected ): library = factories["music.Library"](privacy_level="me", actor__local=True) factories["federation.LibraryFollow"]( actor=authenticated_actor, target=library, approved=approved ) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, {"page": 1}) assert response.status_code == expected @pytest.mark.parametrize( "factory, serializer_class, namespace", [ ("music.Artist", serializers.ArtistSerializer, "artists"), ("music.Album", serializers.AlbumSerializer, "albums"), ("music.Track", serializers.TrackSerializer, "tracks"), ], ) def test_music_local_entity_detail( factories, api_client, factory, serializer_class, namespace, settings ): obj = factories[factory](fid="http://{}/1".format(settings.FEDERATION_HOSTNAME)) url = reverse( "federation:music:{}-detail".format(namespace), kwargs={"uuid": obj.uuid} ) response = api_client.get(url) assert response.status_code == 200 assert response.data == serializer_class(obj).data @pytest.mark.parametrize( "factory, namespace", [("music.Artist", "artists"), ("music.Album", "albums"), ("music.Track", "tracks")], ) def test_music_non_local_entity_detail( factories, api_client, factory, namespace, settings ): obj = factories[factory](fid="http://wrong-domain/1") url = reverse( "federation:music:{}-detail".format(namespace), kwargs={"uuid": obj.uuid} ) response = api_client.get(url) assert response.status_code == 404 @pytest.mark.parametrize( "privacy_level, expected", [("me", 404), ("instance", 404), ("everyone", 200)] ) def test_music_upload_detail(factories, api_client, privacy_level, expected): upload = factories["music.Upload"]( library__privacy_level=privacy_level, library__actor__local=True, import_status="finished", ) url = reverse("federation:music:uploads-detail", kwargs={"uuid": upload.uuid}) response = api_client.get(url) assert response.status_code == expected if expected == 200: assert response.data == serializers.UploadSerializer(upload).data @pytest.mark.parametrize("privacy_level", ["me", "instance"]) def test_music_upload_detail_private_approved_follow( factories, api_client, authenticated_actor, privacy_level ): upload = factories["music.Upload"]( library__privacy_level=privacy_level, library__actor__local=True, import_status="finished", ) factories["federation.LibraryFollow"]( actor=authenticated_actor, target=upload.library, approved=True ) url = reverse("federation:music:uploads-detail", kwargs={"uuid": upload.uuid}) response = api_client.get(url) assert response.status_code == 200 @pytest.mark.parametrize( "accept_header,default,expected", [ ("text/html,application/xhtml+xml", True, True), ("text/html,application/json", True, True), ("", True, False), (None, True, False), ("application/json", True, False), ("application/activity+json", True, False), ("application/json,text/html", True, False), ("application/activity+json,text/html", True, False), ("unrelated/ct", True, True), ("unrelated/ct", False, False), ], ) def test_should_redirect_ap_to_html(accept_header, default, expected): assert ( federation_utils.should_redirect_ap_to_html(accept_header, default) is expected ) def test_music_library_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): library = factories["music.Library"](actor__local=True) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_actor_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): actor = factories["federation.Actor"](local=True) url = reverse( "federation:actors-detail", kwargs={"preferred_username": actor.preferred_username}, ) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse( "actor_detail", kwargs={"username": actor.preferred_username} ), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_channel_actor_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): channel = factories["audio.Channel"](local=True) url = reverse( "federation:actors-detail", kwargs={"preferred_username": channel.actor.preferred_username}, ) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse( "channel_detail", kwargs={"username": channel.actor.preferred_username} ), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_upload_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): upload = factories["music.Upload"](library__local=True, playable=True) url = reverse("federation:music:uploads-detail", kwargs={"uuid": upload.uuid},) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_track", kwargs={"pk": upload.track.pk}), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_track_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): track = factories["music.Track"](local=True) url = reverse("federation:music:tracks-detail", kwargs={"uuid": track.uuid},) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_track", kwargs={"pk": track.pk}), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_album_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): album = factories["music.Album"](local=True) url = reverse("federation:music:albums-detail", kwargs={"uuid": album.uuid},) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_album", kwargs={"pk": album.pk}), ) assert response.status_code == 302 assert response["Location"] == expected_url def test_artist_retrieve_redirects_to_html_if_header_set( factories, api_client, settings ): artist = factories["music.Artist"](local=True) url = reverse("federation:music:artists-detail", kwargs={"uuid": artist.uuid},) response = api_client.get(url, HTTP_ACCEPT="text/html") expected_url = utils.join_url( settings.FUNKWHALE_URL, utils.spa_reverse("library_artist", kwargs={"pk": artist.pk}), ) assert response.status_code == 302 assert response["Location"] == expected_url @pytest.mark.parametrize("index", ["channels", "libraries"]) def test_public_index_disabled(index, api_client, preferences): preferences["federation__public_index"] = False url = reverse("federation:index:index-{}".format(index)) response = api_client.get(url) assert response.status_code == 405 def test_index_channels_retrieve(factories, api_client): channels = [ factories["audio.Channel"](actor__local=True), factories["audio.Channel"](actor__local=True), factories["audio.Channel"](actor__local=True), ] expected = serializers.IndexSerializer( { "id": federation_utils.full_url(reverse("federation:index:index-channels")), "items": channels[0].__class__.objects.order_by("creation_date"), "page_size": 100, }, ).data url = reverse("federation:index:index-channels",) response = api_client.get(url) assert response.status_code == 200 assert response.data == expected def test_index_channels_page(factories, api_client, preferences): preferences["federation__collection_page_size"] = 1 remote_actor = factories["federation.Actor"]() channels = [ factories["audio.Channel"](actor__local=True), factories["audio.Channel"](actor__local=True), factories["audio.Channel"](actor__local=True), factories["audio.Channel"](actor=remote_actor), ] id = federation_utils.full_url(reverse("federation:index:index-channels")) expected = serializers.CollectionPageSerializer( { "id": id, "item_serializer": serializers.ActorSerializer, "page": Paginator([c.actor for c in channels][:3], 1).page(1), "actor": None, } ).data url = reverse("federation:index:index-channels") response = api_client.get(url, {"page": 1}) assert response.status_code == 200 assert response.data == expected def test_index_libraries_retrieve(factories, api_client): remote_actor = factories["federation.Actor"]() libraries = [ factories["music.Library"](actor__local=True, privacy_level="everyone"), factories["music.Library"](actor__local=True, privacy_level="everyone"), factories["music.Library"](actor__local=True, privacy_level="me"), factories["music.Library"](actor=remote_actor, privacy_level="everyone"), ] expected = serializers.IndexSerializer( { "id": federation_utils.full_url( reverse("federation:index:index-libraries") ), "items": libraries[0] .__class__.objects.local() .filter(privacy_level="everyone") .order_by("creation_date"), "page_size": 100, }, ).data url = reverse("federation:index:index-libraries") response = api_client.get(url) assert response.status_code == 200 assert response.data == expected def test_index_libraries_page(factories, api_client, preferences): preferences["federation__collection_page_size"] = 1 remote_actor = factories["federation.Actor"]() libraries = [ factories["music.Library"](actor__local=True, privacy_level="everyone"), factories["music.Library"](actor__local=True, privacy_level="everyone"), factories["music.Library"](actor__local=True, privacy_level="me"), factories["music.Library"](actor=remote_actor, privacy_level="everyone"), ] id = federation_utils.full_url(reverse("federation:index:index-libraries")) expected = serializers.CollectionPageSerializer( { "id": id, "item_serializer": serializers.LibrarySerializer, "page": Paginator(libraries[:2], 1).page(1), "actor": None, } ).data url = reverse("federation:index:index-libraries") response = api_client.get(url, {"page": 1}) assert response.status_code == 200 assert response.data == expected