Audio federation

This commit is contained in:
Eliot Berriot 2018-09-22 12:29:30 +00:00
commit e49a460203
85 changed files with 2591 additions and 1197 deletions

View file

@ -1,21 +1,31 @@
import pytest
import uuid
from funkwhale_api.federation import activity, api_serializers, serializers, tasks
from django.db.models import Q
from django.urls import reverse
from funkwhale_api.federation import (
activity,
models,
api_serializers,
serializers,
tasks,
)
def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker):
mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit")
local_actor = factories["users.User"]().create_actor()
local_to_actor = factories["users.User"]().create_actor()
local_cc_actor = factories["users.User"]().create_actor()
remote_actor = factories["federation.Actor"]()
another_actor = factories["federation.Actor"]()
a = {
"@context": [],
"actor": remote_actor.fid,
"type": "Noop",
"id": "https://test.activity",
"to": [local_actor.fid],
"cc": [another_actor.fid, activity.PUBLIC_ADDRESS],
"to": [local_to_actor.fid, remote_actor.fid],
"cc": [local_cc_actor.fid, activity.PUBLIC_ADDRESS],
}
copy = activity.receive(activity=a, on_behalf_of=remote_actor)
@ -29,8 +39,60 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now,
tasks.dispatch_inbox.delay, activity_id=copy.pk
)
inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid)
assert inbox_item.is_delivered is False
assert models.InboxItem.objects.count() == 2
for actor, t in [(local_to_actor, "to"), (local_cc_actor, "cc")]:
ii = models.InboxItem.objects.get(actor=actor)
assert ii.type == t
assert ii.activity == copy
assert ii.is_read is False
def test_get_actors_from_audience_urls(settings, db):
settings.FEDERATION_HOSTNAME = "federation.hostname"
library_uuid1 = uuid.uuid4()
library_uuid2 = uuid.uuid4()
urls = [
"https://wrong.url",
"https://federation.hostname"
+ reverse("federation:actors-detail", kwargs={"preferred_username": "kevin"}),
"https://federation.hostname"
+ reverse("federation:actors-detail", kwargs={"preferred_username": "alice"}),
"https://federation.hostname"
+ reverse("federation:actors-detail", kwargs={"preferred_username": "bob"}),
"https://federation.hostname"
+ reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid1}),
"https://federation.hostname"
+ reverse("federation:music:libraries-detail", kwargs={"uuid": library_uuid2}),
activity.PUBLIC_ADDRESS,
]
followed_query = Q(target__followers_url=urls[0])
for url in urls[1:-1]:
followed_query |= Q(target__followers_url=url)
actor_follows = models.Follow.objects.filter(followed_query, approved=True)
library_follows = models.LibraryFollow.objects.filter(followed_query, approved=True)
expected = models.Actor.objects.filter(
Q(fid__in=urls[0:-1])
| Q(pk__in=actor_follows.values_list("actor", flat=True))
| Q(pk__in=library_follows.values_list("actor", flat=True))
)
assert str(activity.get_actors_from_audience(urls).query) == str(expected.query)
def test_get_inbox_urls(factories):
a1 = factories["federation.Actor"](
shared_inbox_url=None, inbox_url="https://a1.inbox"
)
a2 = factories["federation.Actor"](
shared_inbox_url="https://shared.inbox", inbox_url="https://a2.inbox"
)
factories["federation.Actor"](
shared_inbox_url="https://shared.inbox", inbox_url="https://a3.inbox"
)
expected = sorted(set([a1.inbox_url, a2.shared_inbox_url]))
assert activity.get_inbox_urls(a1.__class__.objects.all()) == expected
def test_receive_invalid_data(factories):
@ -97,8 +159,6 @@ def test_inbox_routing_send_to_channel(factories, mocker):
ii.refresh_from_db()
assert ii.is_delivered is True
group_send.assert_called_once_with(
"user.{}.inbox".format(ii.actor.user.pk),
{
@ -118,6 +178,16 @@ def test_inbox_routing_send_to_channel(factories, mocker):
({"type": "Follow"}, {"type": "Follow"}, True),
({"type": "Follow"}, {"type": "Noop"}, False),
({"type": "Follow"}, {"type": "Follow", "id": "https://hello"}, True),
(
{"type": "Create", "object.type": "Audio"},
{"type": "Create", "object": {"type": "Note"}},
False,
),
(
{"type": "Create", "object.type": "Audio"},
{"type": "Create", "object": {"type": "Audio"}},
True,
),
],
)
def test_route_matching(route, payload, expected):
@ -126,7 +196,6 @@ def test_route_matching(route, payload, expected):
def test_outbox_router_dispatch(mocker, factories, now):
router = activity.OutboxRouter()
recipient = factories["federation.Actor"]()
actor = factories["federation.Actor"]()
r1 = factories["federation.Actor"]()
r2 = factories["federation.Actor"]()
@ -144,6 +213,9 @@ def test_outbox_router_dispatch(mocker, factories, now):
"actor": actor,
}
expected_deliveries_url = activity.get_inbox_urls(
models.Actor.objects.filter(pk__in=[r1.pk, r2.pk])
)
router.connect({"type": "Noop"}, handler)
activities = router.dispatch({"type": "Noop"}, {"summary": "hello"})
a = activities[0]
@ -163,9 +235,112 @@ def test_outbox_router_dispatch(mocker, factories, now):
assert a.creation_date >= now
assert a.uuid is not None
for recipient, type in [(r1, "to"), (r2, "cc")]:
item = a.inbox_items.get(actor=recipient)
assert item.is_delivered is False
assert item.last_delivery_date is None
assert item.delivery_attempts == 0
assert item.type == type
assert a.deliveries.count() == 2
for url in expected_deliveries_url:
delivery = a.deliveries.get(inbox_url=url)
assert delivery.is_delivered is False
def test_prepare_deliveries_and_inbox_items(factories):
local_actor1 = factories["federation.Actor"](
local=True, shared_inbox_url="https://testlocal.inbox"
)
local_actor2 = factories["federation.Actor"](
local=True, shared_inbox_url=local_actor1.shared_inbox_url
)
local_actor3 = factories["federation.Actor"](local=True, shared_inbox_url=None)
remote_actor1 = factories["federation.Actor"](
shared_inbox_url="https://testremote.inbox"
)
remote_actor2 = factories["federation.Actor"](
shared_inbox_url=remote_actor1.shared_inbox_url
)
remote_actor3 = factories["federation.Actor"](shared_inbox_url=None)
library = factories["music.Library"]()
library_follower_local = factories["federation.LibraryFollow"](
target=library, actor__local=True, approved=True
).actor
library_follower_remote = factories["federation.LibraryFollow"](
target=library, actor__local=False, approved=True
).actor
# follow not approved
factories["federation.LibraryFollow"](
target=library, actor__local=False, approved=False
)
followed_actor = factories["federation.Actor"]()
actor_follower_local = factories["federation.Follow"](
target=followed_actor, actor__local=True, approved=True
).actor
actor_follower_remote = factories["federation.Follow"](
target=followed_actor, actor__local=False, approved=True
).actor
# follow not approved
factories["federation.Follow"](
target=followed_actor, actor__local=False, approved=False
)
recipients = [
local_actor1,
local_actor2,
local_actor3,
remote_actor1,
remote_actor2,
remote_actor3,
activity.PUBLIC_ADDRESS,
{"type": "followers", "target": library},
{"type": "followers", "target": followed_actor},
]
inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items(
recipients, "to"
)
expected_inbox_items = sorted(
[
models.InboxItem(actor=local_actor1, type="to"),
models.InboxItem(actor=local_actor2, type="to"),
models.InboxItem(actor=local_actor3, type="to"),
models.InboxItem(actor=library_follower_local, type="to"),
models.InboxItem(actor=actor_follower_local, type="to"),
],
key=lambda v: v.actor.pk,
)
expected_deliveries = sorted(
[
models.Delivery(inbox_url=remote_actor1.shared_inbox_url),
models.Delivery(inbox_url=remote_actor3.inbox_url),
models.Delivery(inbox_url=library_follower_remote.inbox_url),
models.Delivery(inbox_url=actor_follower_remote.inbox_url),
],
key=lambda v: v.inbox_url,
)
expected_urls = [
local_actor1.fid,
local_actor2.fid,
local_actor3.fid,
remote_actor1.fid,
remote_actor2.fid,
remote_actor3.fid,
activity.PUBLIC_ADDRESS,
library.followers_url,
followed_actor.followers_url,
]
assert urls == expected_urls
assert len(expected_inbox_items) == len(inbox_items)
assert len(expected_deliveries) == len(deliveries)
for delivery, expected_delivery in zip(
sorted(deliveries, key=lambda v: v.inbox_url), expected_deliveries
):
assert delivery.inbox_url == expected_delivery.inbox_url
for inbox_item, expected_inbox_item in zip(
sorted(inbox_items, key=lambda v: v.actor.pk), expected_inbox_items
):
assert inbox_item.actor == expected_inbox_item.actor
assert inbox_item.type == "to"

View file

@ -3,7 +3,7 @@ from funkwhale_api.federation import serializers
def test_library_serializer(factories):
library = factories["music.Library"](files_count=5678)
library = factories["music.Library"](uploads_count=5678)
expected = {
"fid": library.fid,
"uuid": str(library.uuid),
@ -11,7 +11,7 @@ def test_library_serializer(factories):
"name": library.name,
"description": library.description,
"creation_date": library.creation_date.isoformat().split("+")[0] + "Z",
"files_count": library.files_count,
"uploads_count": library.uploads_count,
"privacy_level": library.privacy_level,
"follow": None,
}
@ -22,7 +22,7 @@ def test_library_serializer(factories):
def test_library_serializer_with_follow(factories):
library = factories["music.Library"](files_count=5678)
library = factories["music.Library"](uploads_count=5678)
follow = factories["federation.LibraryFollow"](target=library)
setattr(library, "_follows", [follow])
@ -33,7 +33,7 @@ def test_library_serializer_with_follow(factories):
"name": library.name,
"description": library.description,
"creation_date": library.creation_date.isoformat().split("+")[0] + "Z",
"files_count": library.files_count,
"uploads_count": library.uploads_count,
"privacy_level": library.privacy_level,
"follow": api_serializers.NestedLibraryFollowSerializer(follow).data,
}
@ -53,7 +53,7 @@ def test_library_serializer_validates_existing_follow(factories):
assert "target" in serializer.errors
def test_manage_track_file_action_read(factories):
def test_manage_upload_action_read(factories):
ii = factories["federation.InboxItem"]()
s = api_serializers.InboxItemActionSerializer(queryset=None)

View file

@ -11,6 +11,7 @@ def test_authenticate(factories, mocker, api_request):
"type": "Person",
"outbox": "https://test.com",
"inbox": "https://test.com",
"followers": "https://test.com",
"preferredUsername": "test",
"publicKey": {
"publicKeyPem": public.decode("utf-8"),

View file

@ -27,25 +27,25 @@ def test_follow_federation_url(factories):
def test_actor_get_quota(factories):
library = factories["music.Library"]()
factories["music.TrackFile"](
factories["music.Upload"](
library=library,
import_status="pending",
audio_file__from_path=None,
audio_file__data=b"a",
)
factories["music.TrackFile"](
factories["music.Upload"](
library=library,
import_status="skipped",
audio_file__from_path=None,
audio_file__data=b"aa",
)
factories["music.TrackFile"](
factories["music.Upload"](
library=library,
import_status="errored",
audio_file__from_path=None,
audio_file__data=b"aaa",
)
factories["music.TrackFile"](
factories["music.Upload"](
library=library,
import_status="finished",
audio_file__from_path=None,

View file

@ -8,6 +8,9 @@ from funkwhale_api.federation import routes, serializers
[
({"type": "Follow"}, routes.inbox_follow),
({"type": "Accept"}, routes.inbox_accept),
({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio),
({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
],
)
def test_inbox_routes(route, handler):
@ -24,6 +27,9 @@ def test_inbox_routes(route, handler):
[
({"type": "Accept"}, routes.outbox_accept),
({"type": "Follow"}, routes.outbox_follow),
({"type": "Create", "object.type": "Audio"}, routes.outbox_create_audio),
({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
],
)
def test_outbox_routes(route, handler):
@ -155,3 +161,153 @@ def test_outbox_follow_library(factories, mocker):
assert activity["payload"] == expected
assert activity["actor"] == follow.actor
assert activity["object"] == follow.target
def test_outbox_create_audio(factories, mocker):
upload = factories["music.Upload"]()
activity = list(routes.outbox_create_audio({"upload": upload}))[0]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": serializers.UploadSerializer(upload).data,
"actor": upload.library.actor.fid,
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": upload.library}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == upload.library.actor
assert activity["target"] == upload.library
assert activity["object"] == upload
def test_inbox_create_audio(factories, mocker):
activity = factories["federation.Activity"]()
upload = factories["music.Upload"](bitrate=42, duration=55)
payload = {
"type": "Create",
"actor": upload.library.actor.fid,
"object": serializers.UploadSerializer(upload).data,
}
library = upload.library
upload.delete()
init = mocker.spy(serializers.UploadSerializer, "__init__")
save = mocker.spy(serializers.UploadSerializer, "save")
assert library.uploads.count() == 0
result = routes.inbox_create_audio(
payload,
context={"actor": library.actor, "raise_exception": True, "activity": activity},
)
assert library.uploads.count() == 1
assert result == {"object": library.uploads.latest("id"), "target": library}
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == payload["object"]
assert args[1]["context"] == {"activity": activity, "actor": library.actor}
assert save.call_count == 1
def test_inbox_delete_library(factories):
activity = factories["federation.Activity"]()
library = factories["music.Library"]()
payload = {
"type": "Delete",
"actor": library.actor.fid,
"object": {"type": "Library", "id": library.fid},
}
routes.inbox_delete_library(
payload,
context={"actor": library.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(library.__class__.DoesNotExist):
library.refresh_from_db()
def test_inbox_delete_library_impostor(factories):
activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]()
library = factories["music.Library"]()
payload = {
"type": "Delete",
"actor": library.actor.fid,
"object": {"type": "Library", "id": library.fid},
}
routes.inbox_delete_library(
payload,
context={"actor": impostor, "raise_exception": True, "activity": activity},
)
# not deleted, should still be here
library.refresh_from_db()
def test_outbox_delete_library(factories):
library = factories["music.Library"]()
activity = list(routes.outbox_delete_library({"library": library}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Library", "id": library.fid}}
).data
expected["to"] = [{"type": "followers", "target": library}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == library.actor
def test_inbox_delete_audio(factories):
activity = factories["federation.Activity"]()
upload = factories["music.Upload"]()
library = upload.library
payload = {
"type": "Delete",
"actor": library.actor.fid,
"object": {"type": "Audio", "id": [upload.fid]},
}
routes.inbox_delete_audio(
payload,
context={"actor": library.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(upload.__class__.DoesNotExist):
upload.refresh_from_db()
def test_inbox_delete_audio_impostor(factories):
activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]()
upload = factories["music.Upload"]()
library = upload.library
payload = {
"type": "Delete",
"actor": library.actor.fid,
"object": {"type": "Audio", "id": [upload.fid]},
}
routes.inbox_delete_audio(
payload,
context={"actor": impostor, "raise_exception": True, "activity": activity},
)
# not deleted, should still be here
upload.refresh_from_db()
def test_outbox_delete_audio(factories):
upload = factories["music.Upload"]()
activity = list(routes.outbox_delete_audio({"uploads": [upload]}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Audio", "id": [upload.fid]}}
).data
expected["to"] = [{"type": "followers", "target": upload.library}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == upload.library.actor

View file

@ -1,7 +1,10 @@
import pytest
from django.core.paginator import Paginator
import uuid
from funkwhale_api.federation import activity, models, serializers, utils
from django.core.paginator import Paginator
from django.utils import timezone
from funkwhale_api.federation import models, serializers, utils
def test_actor_serializer_from_ap(db):
@ -336,13 +339,13 @@ def test_undo_follow_serializer_validates_on_context(factories):
def test_paginated_collection_serializer(factories):
tfs = factories["music.TrackFile"].create_batch(size=5)
uploads = factories["music.Upload"].create_batch(size=5)
actor = factories["federation.Actor"](local=True)
conf = {
"id": "https://test.federation/test",
"items": tfs,
"item_serializer": serializers.AudioSerializer,
"items": uploads,
"item_serializer": serializers.UploadSerializer,
"actor": actor,
"page_size": 2,
}
@ -355,7 +358,7 @@ def test_paginated_collection_serializer(factories):
"type": "Collection",
"id": conf["id"],
"actor": actor.fid,
"totalItems": len(tfs),
"totalItems": len(uploads),
"current": conf["id"] + "?page=1",
"last": conf["id"] + "?page=3",
"first": conf["id"] + "?page=1",
@ -425,7 +428,7 @@ def test_collection_page_serializer_can_validate_child():
}
serializer = serializers.CollectionPageSerializer(
data=data, context={"item_serializer": serializers.AudioSerializer}
data=data, context={"item_serializer": serializers.UploadSerializer}
)
# child are validated but not included in data if not valid
@ -434,14 +437,14 @@ def test_collection_page_serializer_can_validate_child():
def test_collection_page_serializer(factories):
tfs = factories["music.TrackFile"].create_batch(size=5)
uploads = factories["music.Upload"].create_batch(size=5)
actor = factories["federation.Actor"](local=True)
conf = {
"id": "https://test.federation/test",
"item_serializer": serializers.AudioSerializer,
"item_serializer": serializers.UploadSerializer,
"actor": actor,
"page": Paginator(tfs, 2).page(2),
"page": Paginator(uploads, 2).page(2),
}
expected = {
"@context": [
@ -452,7 +455,7 @@ def test_collection_page_serializer(factories):
"type": "CollectionPage",
"id": conf["id"] + "?page=2",
"actor": actor.fid,
"totalItems": len(tfs),
"totalItems": len(uploads),
"partOf": conf["id"],
"prev": conf["id"] + "?page=1",
"next": conf["id"] + "?page=3",
@ -471,38 +474,12 @@ def test_collection_page_serializer(factories):
assert serializer.data == expected
def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories):
remote_library = factories["music.Library"]()
tf = factories["music.TrackFile"].build(library=remote_library)
data = serializers.AudioSerializer(tf).data
serializer1 = serializers.AudioSerializer(data=data)
serializer2 = serializers.AudioSerializer(data=data)
assert serializer1.is_valid(raise_exception=True) is True
assert serializer2.is_valid(raise_exception=True) is True
tf1 = serializer1.save()
tf2 = serializer2.save()
assert tf1 == tf2
assert tf1.library == remote_library
assert tf1.source == utils.full_url(tf.listen_url)
assert tf1.mimetype == tf.mimetype
assert tf1.bitrate == tf.bitrate
assert tf1.duration == tf.duration
assert tf1.size == tf.size
assert tf1.metadata == data
assert tf1.fid == tf.get_federation_id()
assert not tf1.audio_file
def test_music_library_serializer_to_ap(factories):
library = factories["music.Library"]()
# pending, errored and skippednot included
factories["music.TrackFile"](import_status="pending")
factories["music.TrackFile"](import_status="errored")
factories["music.TrackFile"](import_status="finished")
factories["music.Upload"](import_status="pending")
factories["music.Upload"](import_status="errored")
factories["music.Upload"](import_status="finished")
serializer = serializers.LibrarySerializer(library)
expected = {
"@context": [
@ -520,6 +497,7 @@ def test_music_library_serializer_to_ap(factories):
"current": library.fid + "?page=1",
"last": library.fid + "?page=1",
"first": library.fid + "?page=1",
"followers": library.followers_url,
}
assert serializer.data == expected
@ -541,6 +519,7 @@ def test_music_library_serializer_from_public(factories, mocker):
"summary": "World",
"type": "Library",
"id": "https://library.id",
"followers": "https://library.id/followers",
"actor": actor.fid,
"totalItems": 12,
"first": "https://library.id?page=1",
@ -554,10 +533,12 @@ def test_music_library_serializer_from_public(factories, mocker):
assert library.actor == actor
assert library.fid == data["id"]
assert library.files_count == data["totalItems"]
assert library.uploads_count == data["totalItems"]
assert library.privacy_level == "everyone"
assert library.name == "Hello"
assert library.description == "World"
assert library.followers_url == data["followers"]
retrieve.assert_called_once_with(
actor.fid,
queryset=actor.__class__,
@ -581,6 +562,7 @@ def test_music_library_serializer_from_private(factories, mocker):
"summary": "World",
"type": "Library",
"id": "https://library.id",
"followers": "https://library.id/followers",
"actor": actor.fid,
"totalItems": 12,
"first": "https://library.id?page=1",
@ -594,10 +576,11 @@ def test_music_library_serializer_from_private(factories, mocker):
assert library.actor == actor
assert library.fid == data["id"]
assert library.files_count == data["totalItems"]
assert library.uploads_count == data["totalItems"]
assert library.privacy_level == "me"
assert library.name == "Hello"
assert library.description == "World"
assert library.followers_url == data["followers"]
retrieve.assert_called_once_with(
actor.fid,
queryset=actor.__class__,
@ -605,75 +588,349 @@ def test_music_library_serializer_from_private(factories, mocker):
)
@pytest.mark.parametrize(
"model,serializer_class",
[
("music.Artist", serializers.ArtistSerializer),
("music.Album", serializers.AlbumSerializer),
("music.Track", serializers.TrackSerializer),
],
)
def test_music_entity_serializer_create_existing_mbid(
model, serializer_class, factories
):
entity = factories[model]()
data = {"musicbrainzId": str(entity.mbid), "id": "https://noop"}
serializer = serializer_class()
assert serializer.create(data) == entity
@pytest.mark.parametrize(
"model,serializer_class",
[
("music.Artist", serializers.ArtistSerializer),
("music.Album", serializers.AlbumSerializer),
("music.Track", serializers.TrackSerializer),
],
)
def test_music_entity_serializer_create_existing_fid(
model, serializer_class, factories
):
entity = factories[model](fid="https://entity.url")
data = {"musicbrainzId": None, "id": "https://entity.url"}
serializer = serializer_class()
assert serializer.create(data) == entity
def test_activity_pub_artist_serializer_to_ap(factories):
artist = factories["music.Artist"]()
expected = {
"@context": serializers.AP_CONTEXT,
"type": "Artist",
"id": artist.fid,
"name": artist.name,
"musicbrainzId": artist.mbid,
"published": artist.creation_date.isoformat(),
}
serializer = serializers.ArtistSerializer(artist)
assert serializer.data == expected
def test_activity_pub_artist_serializer_from_ap(factories):
activity = factories["federation.Activity"]()
published = timezone.now()
data = {
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
serializer = serializers.ArtistSerializer(data=data, context={"activity": activity})
assert serializer.is_valid(raise_exception=True)
artist = serializer.save()
assert artist.from_activity == activity
assert artist.name == data["name"]
assert artist.fid == data["id"]
assert str(artist.mbid) == data["musicbrainzId"]
assert artist.creation_date == published
def test_activity_pub_album_serializer_to_ap(factories):
album = factories["music.Album"]()
expected = {
"@context": serializers.AP_CONTEXT,
"type": "Album",
"id": album.fid,
"name": album.title,
"cover": {"type": "Image", "url": utils.full_url(album.cover.url)},
"musicbrainzId": album.mbid,
"published": album.creation_date.isoformat(),
"released": album.release_date.isoformat(),
"artists": [
serializers.ArtistSerializer(
album.artist, context={"include_ap_context": False}
).data
],
}
serializer = serializers.AlbumSerializer(album)
assert serializer.data == expected
def test_activity_pub_album_serializer_from_ap(factories):
activity = factories["federation.Activity"]()
published = timezone.now()
released = timezone.now().date()
data = {
"type": "Album",
"id": "http://hello.album",
"name": "Purple album",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"released": released.isoformat(),
"artists": [
{
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
],
}
serializer = serializers.AlbumSerializer(data=data, context={"activity": activity})
assert serializer.is_valid(raise_exception=True)
album = serializer.save()
artist = album.artist
assert album.from_activity == activity
assert album.title == data["name"]
assert album.fid == data["id"]
assert str(album.mbid) == data["musicbrainzId"]
assert album.creation_date == published
assert album.release_date == released
assert artist.from_activity == activity
assert artist.name == data["artists"][0]["name"]
assert artist.fid == data["artists"][0]["id"]
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
assert artist.creation_date == published
def test_activity_pub_track_serializer_to_ap(factories):
track = factories["music.Track"]()
expected = {
"@context": serializers.AP_CONTEXT,
"published": track.creation_date.isoformat(),
"type": "Track",
"musicbrainzId": track.mbid,
"id": track.fid,
"name": track.title,
"position": track.position,
"artists": [
serializers.ArtistSerializer(
track.artist, context={"include_ap_context": False}
).data
],
"album": serializers.AlbumSerializer(
track.album, context={"include_ap_context": False}
).data,
}
serializer = serializers.TrackSerializer(track)
assert serializer.data == expected
def test_activity_pub_track_serializer_from_ap(factories):
activity = factories["federation.Activity"]()
published = timezone.now()
released = timezone.now().date()
data = {
"type": "Track",
"id": "http://hello.track",
"published": published.isoformat(),
"musicbrainzId": str(uuid.uuid4()),
"name": "Black in back",
"position": 5,
"album": {
"type": "Album",
"id": "http://hello.album",
"name": "Purple album",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"released": released.isoformat(),
"artists": [
{
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
],
},
"artists": [
{
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
],
}
serializer = serializers.TrackSerializer(data=data, context={"activity": activity})
assert serializer.is_valid(raise_exception=True)
track = serializer.save()
album = track.album
artist = track.artist
assert track.from_activity == activity
assert track.fid == data["id"]
assert track.title == data["name"]
assert track.position == data["position"]
assert track.creation_date == published
assert str(track.mbid) == data["musicbrainzId"]
assert album.from_activity == activity
assert album.title == data["album"]["name"]
assert album.fid == data["album"]["id"]
assert str(album.mbid) == data["album"]["musicbrainzId"]
assert album.creation_date == published
assert album.release_date == released
assert artist.from_activity == activity
assert artist.name == data["artists"][0]["name"]
assert artist.fid == data["artists"][0]["id"]
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
assert artist.creation_date == published
def test_activity_pub_upload_serializer_from_ap(factories, mocker):
activity = factories["federation.Activity"]()
library = factories["music.Library"]()
published = timezone.now()
updated = timezone.now()
released = timezone.now().date()
data = {
"@context": serializers.AP_CONTEXT,
"type": "Audio",
"id": "https://track.file",
"name": "Ignored",
"published": published.isoformat(),
"updated": updated.isoformat(),
"duration": 43,
"bitrate": 42,
"size": 66,
"url": {"href": "https://audio.file", "type": "Link", "mediaType": "audio/mp3"},
"library": library.fid,
"track": {
"type": "Track",
"id": "http://hello.track",
"published": published.isoformat(),
"musicbrainzId": str(uuid.uuid4()),
"name": "Black in back",
"position": 5,
"album": {
"type": "Album",
"id": "http://hello.album",
"name": "Purple album",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
"released": released.isoformat(),
"artists": [
{
"type": "Artist",
"id": "http://hello.artist",
"name": "John Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
],
},
"artists": [
{
"type": "Artist",
"id": "http://hello.trackartist",
"name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()),
"published": published.isoformat(),
}
],
},
}
serializer = serializers.UploadSerializer(data=data, context={"activity": activity})
assert serializer.is_valid(raise_exception=True)
track_create = mocker.spy(serializers.TrackSerializer, "create")
upload = serializer.save()
assert upload.track.from_activity == activity
assert upload.from_activity == activity
assert track_create.call_count == 1
assert upload.fid == data["id"]
assert upload.track.fid == data["track"]["id"]
assert upload.duration == data["duration"]
assert upload.size == data["size"]
assert upload.bitrate == data["bitrate"]
assert upload.source == data["url"]["href"]
assert upload.mimetype == data["url"]["mediaType"]
assert upload.creation_date == published
assert upload.import_status == "finished"
assert upload.modification_date == updated
def test_activity_pub_upload_serializer_validtes_library_actor(factories, mocker):
library = factories["music.Library"]()
usurpator = factories["federation.Actor"]()
serializer = serializers.UploadSerializer(data={}, context={"actor": usurpator})
with pytest.raises(serializers.serializers.ValidationError):
serializer.validate_library(library.fid)
def test_activity_pub_audio_serializer_to_ap(factories):
tf = factories["music.TrackFile"](
upload = factories["music.Upload"](
mimetype="audio/mp3", bitrate=42, duration=43, size=44
)
expected = {
"@context": serializers.AP_CONTEXT,
"type": "Audio",
"id": tf.get_federation_id(),
"name": tf.track.full_name,
"published": tf.creation_date.isoformat(),
"updated": tf.modification_date.isoformat(),
"metadata": {
"artist": {
"musicbrainz_id": tf.track.artist.mbid,
"name": tf.track.artist.name,
},
"release": {
"musicbrainz_id": tf.track.album.mbid,
"title": tf.track.album.title,
},
"recording": {"musicbrainz_id": tf.track.mbid, "title": tf.track.title},
"size": tf.size,
"length": tf.duration,
"bitrate": tf.bitrate,
},
"id": upload.fid,
"name": upload.track.full_name,
"published": upload.creation_date.isoformat(),
"updated": upload.modification_date.isoformat(),
"duration": upload.duration,
"bitrate": upload.bitrate,
"size": upload.size,
"url": {
"href": utils.full_url(tf.listen_url),
"href": utils.full_url(upload.listen_url),
"type": "Link",
"mediaType": "audio/mp3",
},
"library": tf.library.get_federation_id(),
"library": upload.library.fid,
"track": serializers.TrackSerializer(
upload.track, context={"include_ap_context": False}
).data,
}
serializer = serializers.AudioSerializer(tf)
assert serializer.data == expected
def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
tf = factories["music.TrackFile"](
mimetype="audio/mp3",
track__mbid=None,
track__album__mbid=None,
track__album__artist__mbid=None,
)
expected = {
"@context": serializers.AP_CONTEXT,
"type": "Audio",
"id": tf.get_federation_id(),
"name": tf.track.full_name,
"published": tf.creation_date.isoformat(),
"updated": tf.modification_date.isoformat(),
"metadata": {
"artist": {"name": tf.track.artist.name, "musicbrainz_id": None},
"release": {"title": tf.track.album.title, "musicbrainz_id": None},
"recording": {"title": tf.track.title, "musicbrainz_id": None},
"size": tf.size,
"length": None,
"bitrate": None,
},
"url": {
"href": utils.full_url(tf.listen_url),
"type": "Link",
"mediaType": "audio/mp3",
},
"library": tf.library.fid,
}
serializer = serializers.AudioSerializer(tf)
serializer = serializers.UploadSerializer(upload)
assert serializer.data == expected
@ -731,7 +988,7 @@ def test_local_actor_serializer_to_ap(factories):
assert serializer.data == expected
def test_activity_serializer_clean_recipients_empty(db):
def test_activity_serializer_validate_recipients_empty(db):
s = serializers.BaseActivitySerializer()
with pytest.raises(serializers.serializers.ValidationError):
@ -742,32 +999,3 @@ def test_activity_serializer_clean_recipients_empty(db):
with pytest.raises(serializers.serializers.ValidationError):
s.validate_recipients({"cc": []})
with pytest.raises(serializers.serializers.ValidationError):
s.validate_recipients({"to": ["nope"]})
with pytest.raises(serializers.serializers.ValidationError):
s.validate_recipients({"cc": ["nope"]})
def test_activity_serializer_clean_recipients(factories):
r1, r2, r3 = factories["federation.Actor"].create_batch(size=3)
s = serializers.BaseActivitySerializer()
expected = {"to": [r1, r2], "cc": [r3, activity.PUBLIC_ADDRESS]}
assert (
s.validate_recipients(
{"to": [r1.fid, r2.fid], "cc": [r3.fid, activity.PUBLIC_ADDRESS]}
)
== expected
)
def test_activity_serializer_clean_recipients_local(factories):
r = factories["federation.Actor"]()
s = serializers.BaseActivitySerializer(context={"local_recipients": True})
with pytest.raises(serializers.serializers.ValidationError):
s.validate_recipients({"to": [r]})

View file

@ -11,27 +11,27 @@ from funkwhale_api.federation import tasks
def test_clean_federation_music_cache_if_no_listen(preferences, factories):
preferences["federation__music_cache_duration"] = 60
remote_library = factories["music.Library"]()
tf1 = factories["music.TrackFile"](
upload1 = factories["music.Upload"](
library=remote_library, accessed_date=timezone.now()
)
tf2 = factories["music.TrackFile"](
upload2 = factories["music.Upload"](
library=remote_library,
accessed_date=timezone.now() - datetime.timedelta(minutes=61),
)
tf3 = factories["music.TrackFile"](library=remote_library, accessed_date=None)
path1 = tf1.audio_file.path
path2 = tf2.audio_file.path
path3 = tf3.audio_file.path
upload3 = factories["music.Upload"](library=remote_library, accessed_date=None)
path1 = upload1.audio_file.path
path2 = upload2.audio_file.path
path3 = upload3.audio_file.path
tasks.clean_music_cache()
tf1.refresh_from_db()
tf2.refresh_from_db()
tf3.refresh_from_db()
upload1.refresh_from_db()
upload2.refresh_from_db()
upload3.refresh_from_db()
assert bool(tf1.audio_file) is True
assert bool(tf2.audio_file) is False
assert bool(tf3.audio_file) is False
assert bool(upload1.audio_file) is True
assert bool(upload2.audio_file) is False
assert bool(upload3.audio_file) is False
assert os.path.exists(path1) is True
assert os.path.exists(path2) is False
assert os.path.exists(path3) is False
@ -46,16 +46,16 @@ def test_clean_federation_music_cache_orphaned(settings, preferences, factories)
os.makedirs(os.path.dirname(remove_path), exist_ok=True)
pathlib.Path(keep_path).touch()
pathlib.Path(remove_path).touch()
tf = factories["music.TrackFile"](
upload = factories["music.Upload"](
accessed_date=timezone.now(), audio_file__path=keep_path
)
tasks.clean_music_cache()
tf.refresh_from_db()
upload.refresh_from_db()
assert bool(tf.audio_file) is True
assert os.path.exists(tf.audio_file.path) is True
assert bool(upload.audio_file) is True
assert os.path.exists(upload.audio_file.path) is True
assert os.path.exists(remove_path) is False
@ -73,168 +73,47 @@ def test_handle_in(factories, mocker, now, queryset_equal_list):
a.payload, context={"actor": a.actor, "activity": a, "inbox_items": [ii1, ii2]}
)
ii1.refresh_from_db()
ii2.refresh_from_db()
assert ii1.is_delivered is True
assert ii2.is_delivered is True
assert ii1.last_delivery_date == now
assert ii2.last_delivery_date == now
def test_handle_in_error(factories, mocker, now):
mocker.patch(
"funkwhale_api.federation.routes.inbox.dispatch", side_effect=Exception()
)
r1 = factories["users.User"](with_actor=True).actor
r2 = factories["users.User"](with_actor=True).actor
a = factories["federation.Activity"](payload={"hello": "world"})
factories["federation.InboxItem"](activity=a, actor=r1)
factories["federation.InboxItem"](activity=a, actor=r2)
with pytest.raises(Exception):
tasks.dispatch_inbox(activity_id=a.pk)
assert a.inbox_items.filter(is_delivered=False).count() == 2
def test_dispatch_outbox_to_inbox(factories, mocker):
def test_dispatch_outbox(factories, mocker):
mocked_inbox = mocker.patch("funkwhale_api.federation.tasks.dispatch_inbox.delay")
mocked_deliver_to_remote_inbox = mocker.patch(
"funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay"
mocked_deliver_to_remote = mocker.patch(
"funkwhale_api.federation.tasks.deliver_to_remote.delay"
)
activity = factories["federation.Activity"](actor__local=True)
factories["federation.InboxItem"](activity=activity, actor__local=True)
remote_ii = factories["federation.InboxItem"](
activity=activity,
actor__shared_inbox_url=None,
actor__inbox_url="https://test.inbox",
)
factories["federation.InboxItem"](activity=activity)
delivery = factories["federation.Delivery"](activity=activity)
tasks.dispatch_outbox(activity_id=activity.pk)
mocked_inbox.assert_called_once_with(activity_id=activity.pk)
mocked_deliver_to_remote_inbox.assert_called_once_with(
activity_id=activity.pk, inbox_url=remote_ii.actor.inbox_url
)
mocked_deliver_to_remote.assert_called_once_with(delivery_id=delivery.pk)
def test_dispatch_outbox_to_shared_inbox_url(factories, mocker):
mocked_deliver_to_remote_inbox = mocker.patch(
"funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay"
)
activity = factories["federation.Activity"](actor__local=True)
# shared inbox
remote_ii_shared1 = factories["federation.InboxItem"](
activity=activity, actor__shared_inbox_url="https://shared.inbox"
)
# another on the same shared inbox
factories["federation.InboxItem"](
activity=activity, actor__shared_inbox_url="https://shared.inbox"
)
# one on a dedicated inbox
remote_ii_single = factories["federation.InboxItem"](
activity=activity,
actor__shared_inbox_url=None,
actor__inbox_url="https://single.inbox",
)
tasks.dispatch_outbox(activity_id=activity.pk)
def test_deliver_to_remote_success_mark_as_delivered(factories, r_mock, now):
delivery = factories["federation.Delivery"]()
r_mock.post(delivery.inbox_url)
tasks.deliver_to_remote(delivery_id=delivery.pk)
assert mocked_deliver_to_remote_inbox.call_count == 2
mocked_deliver_to_remote_inbox.assert_any_call(
activity_id=activity.pk,
shared_inbox_url=remote_ii_shared1.actor.shared_inbox_url,
)
mocked_deliver_to_remote_inbox.assert_any_call(
activity_id=activity.pk, inbox_url=remote_ii_single.actor.inbox_url
)
def test_deliver_to_remote_inbox_inbox_url(factories, r_mock):
activity = factories["federation.Activity"]()
url = "https://test.shared/"
r_mock.post(url)
tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
delivery.refresh_from_db()
request = r_mock.request_history[0]
assert delivery.is_delivered is True
assert delivery.attempts == 1
assert delivery.last_attempt_date == now
assert r_mock.called is True
assert r_mock.call_count == 1
assert request.url == url
assert request.url == delivery.inbox_url
assert request.headers["content-type"] == "application/activity+json"
assert request.json() == activity.payload
assert request.json() == delivery.activity.payload
def test_deliver_to_remote_inbox_shared_inbox_url(factories, r_mock):
activity = factories["federation.Activity"]()
url = "https://test.shared/"
r_mock.post(url)
def test_deliver_to_remote_error(factories, r_mock, now):
delivery = factories["federation.Delivery"]()
r_mock.post(delivery.inbox_url, status_code=404)
tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url)
request = r_mock.request_history[0]
assert r_mock.called is True
assert r_mock.call_count == 1
assert request.url == url
assert request.headers["content-type"] == "application/activity+json"
assert request.json() == activity.payload
def test_deliver_to_remote_inbox_success_shared_inbox_marks_inbox_items_as_delivered(
factories, r_mock, now
):
activity = factories["federation.Activity"]()
url = "https://test.shared/"
r_mock.post(url)
ii = factories["federation.InboxItem"](
activity=activity, actor__shared_inbox_url=url
)
other_ii = factories["federation.InboxItem"](
activity=activity, actor__shared_inbox_url="https://other.url"
)
tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url)
ii.refresh_from_db()
other_ii.refresh_from_db()
assert ii.is_delivered is True
assert ii.last_delivery_date == now
assert other_ii.is_delivered is False
assert other_ii.last_delivery_date is None
def test_deliver_to_remote_inbox_success_single_inbox_marks_inbox_items_as_delivered(
factories, r_mock, now
):
activity = factories["federation.Activity"]()
url = "https://test.single/"
r_mock.post(url)
ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url)
other_ii = factories["federation.InboxItem"](
activity=activity, actor__inbox_url="https://other.url"
)
tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
ii.refresh_from_db()
other_ii.refresh_from_db()
assert ii.is_delivered is True
assert ii.last_delivery_date == now
assert other_ii.is_delivered is False
assert other_ii.last_delivery_date is None
def test_deliver_to_remote_inbox_error(factories, r_mock, now):
activity = factories["federation.Activity"]()
url = "https://test.single/"
r_mock.post(url, status_code=404)
ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url)
with pytest.raises(tasks.RequestException):
tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
tasks.deliver_to_remote(delivery_id=delivery.pk)
ii.refresh_from_db()
delivery.refresh_from_db()
assert ii.is_delivered is False
assert ii.last_delivery_date == now
assert ii.delivery_attempts == 1
assert delivery.is_delivered is False
assert delivery.attempts == 1
assert delivery.last_attempt_date == now

View file

@ -109,6 +109,17 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act
)
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")
@ -138,14 +149,14 @@ def test_music_library_retrieve(factories, api_client, privacy_level):
def test_music_library_retrieve_page_public(factories, api_client):
library = factories["music.Library"](privacy_level="everyone")
tf = factories["music.TrackFile"](library=library)
upload = factories["music.Upload"](library=library)
id = library.get_federation_id()
expected = serializers.CollectionPageSerializer(
{
"id": id,
"item_serializer": serializers.AudioSerializer,
"item_serializer": serializers.UploadSerializer,
"actor": library.actor,
"page": Paginator([tf], 1).page(1),
"page": Paginator([upload], 1).page(1),
"name": library.name,
"summary": library.description,
}