See #852: improved routing logic for federation messages (support multiple objects types for one route)

This commit is contained in:
Eliot Berriot 2019-09-21 16:20:49 +02:00
commit 9f3182caf7
19 changed files with 561 additions and 54 deletions

View file

@ -1,6 +1,13 @@
import pytest
from funkwhale_api.federation import actors, contexts, jsonld, routes, serializers
from funkwhale_api.federation import (
activity,
actors,
contexts,
jsonld,
routes,
serializers,
)
@pytest.mark.parametrize(
@ -8,23 +15,29 @@ from funkwhale_api.federation import actors, contexts, jsonld, routes, serialize
[
({"type": "Follow"}, routes.inbox_follow),
({"type": "Accept"}, routes.inbox_accept),
({"type": "Create", "object.type": "Audio"}, routes.inbox_create_audio),
({"type": "Update", "object.type": "Library"}, routes.inbox_update_library),
({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow),
({"type": "Update", "object.type": "Artist"}, routes.inbox_update_artist),
({"type": "Update", "object.type": "Album"}, routes.inbox_update_album),
({"type": "Update", "object.type": "Track"}, routes.inbox_update_track),
({"type": "Create", "object": {"type": "Audio"}}, routes.inbox_create_audio),
(
{"type": "Update", "object": {"type": "Library"}},
routes.inbox_update_library,
),
(
{"type": "Delete", "object": {"type": "Library"}},
routes.inbox_delete_library,
),
({"type": "Delete", "object": {"type": "Audio"}}, routes.inbox_delete_audio),
({"type": "Undo", "object": {"type": "Follow"}}, routes.inbox_undo_follow),
({"type": "Update", "object": {"type": "Artist"}}, routes.inbox_update_artist),
({"type": "Update", "object": {"type": "Album"}}, routes.inbox_update_album),
({"type": "Update", "object": {"type": "Track"}}, routes.inbox_update_track),
({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
],
)
def test_inbox_routes(route, handler):
for r, h in routes.inbox.routes:
if r == route:
assert h == handler
return
assert False, "Inbox route {} not found".format(route)
matching = [
handler for r, handler in routes.inbox.routes if activity.match_route(r, route)
]
assert len(matching) == 1, "Inbox route {} not found".format(route)
assert matching[0] == handler
@pytest.mark.parametrize(
@ -32,21 +45,41 @@ 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": "Update", "object.type": "Library"}, routes.outbox_update_library),
({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
({"type": "Update", "object.type": "Track"}, routes.outbox_update_track),
({"type": "Create", "object": {"type": "Audio"}}, routes.outbox_create_audio),
(
{"type": "Update", "object": {"type": "Library"}},
routes.outbox_update_library,
),
(
{"type": "Delete", "object": {"type": "Library"}},
routes.outbox_delete_library,
),
({"type": "Delete", "object": {"type": "Audio"}}, routes.outbox_delete_audio),
({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow),
({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track),
(
{"type": "Delete", "object": {"type": "Tombstone"}},
routes.outbox_delete_actor,
),
({"type": "Delete", "object": {"type": "Person"}}, routes.outbox_delete_actor),
({"type": "Delete", "object": {"type": "Service"}}, routes.outbox_delete_actor),
(
{"type": "Delete", "object": {"type": "Application"}},
routes.outbox_delete_actor,
),
({"type": "Delete", "object": {"type": "Group"}}, routes.outbox_delete_actor),
(
{"type": "Delete", "object": {"type": "Organization"}},
routes.outbox_delete_actor,
),
],
)
def test_outbox_routes(route, handler):
for r, h in routes.outbox.routes:
if r == route:
assert h == handler
return
assert False, "Outbox route {} not found".format(route)
matching = [
handler for r, handler in routes.outbox.routes if activity.match_route(r, route)
]
assert len(matching) == 1, "Outbox route {} not found".format(route)
assert matching[0] == handler
def test_inbox_follow_library_autoapprove(factories, mocker):
@ -559,3 +592,60 @@ def test_outbox_update_track(factories):
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == actors.get_service_actor()
def test_outbox_delete_actor(factories):
user = factories["users.User"]()
actor = user.create_actor()
activity = list(routes.outbox_delete_actor({"actor": actor}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"id": actor.fid, "type": actor.type}}
).data
expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == actor
def test_inbox_delete_actor(factories):
remote_actor = factories["federation.Actor"]()
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
"object": {"type": remote_actor.type, "id": remote_actor.fid},
}
)
routes.inbox_delete_actor(
serializer.data, context={"actor": remote_actor, "raise_exception": True}
)
with pytest.raises(remote_actor.__class__.DoesNotExist):
remote_actor.refresh_from_db()
def test_inbox_delete_actor_only_works_on_self(factories):
remote_actor1 = factories["federation.Actor"]()
remote_actor2 = factories["federation.Actor"]()
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
"object": {"type": remote_actor2.type, "id": remote_actor2.fid},
}
)
routes.inbox_delete_actor(
serializer.data, context={"actor": remote_actor1, "raise_exception": True}
)
remote_actor2.refresh_from_db()
def test_inbox_delete_actor_doesnt_delete_local_actor(factories):
local_actor = factories["users.User"]().create_actor()
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": local_actor.type, "id": local_actor.fid}}
)
routes.inbox_delete_actor(
serializer.data, context={"actor": local_actor, "raise_exception": True}
)
# actor should still be here!
local_actor.refresh_from_db()

View file

@ -220,13 +220,3 @@ def test_user_get_quota_status(factories, preferences, mocker):
"errored": 3,
"finished": 4,
}
def test_deleting_users_deletes_associated_actor(factories):
actor = factories["federation.Actor"]()
user = factories["users.User"](actor=actor)
user.delete()
with pytest.raises(actor.DoesNotExist):
actor.refresh_from_db()

View file

@ -0,0 +1,33 @@
from funkwhale_api.users import tasks
def test_delete_account_mutation(mocker, factories, now):
user = factories["users.User"](subsonic_api_token="test", password="test")
actor = user.create_actor()
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
secret_key = user.secret_key
set_unusable_password = mocker.spy(user, "set_unusable_password")
factories["users.Grant"](user=user)
factories["users.AccessToken"](user=user)
factories["users.RefreshToken"](user=user)
mutation = factories["common.Mutation"](
type="delete_account", target=actor, payload={}
)
mutation.apply()
user.refresh_from_db()
set_unusable_password.assert_called_once_with()
assert user.has_usable_password() is False
assert user.subsonic_api_token is None
assert user.secret_key is not None and user.secret_key != secret_key
assert user.users_grant.count() == 0
assert user.users_refreshtoken.count() == 0
assert user.users_accesstoken.count() == 0
on_commit.assert_called_once_with(tasks.delete_account.delay, user_id=user.pk)
assert mutation.previous_state == {
"actor": {"preferred_username": actor.preferred_username},
"user": {"username": user.username, "id": user.pk},
}

View file

@ -0,0 +1,32 @@
import pytest
from funkwhale_api.federation import routes
from funkwhale_api.users import tasks
def test_delete_account(factories, mocker):
user = factories["users.User"]()
actor = user.create_actor()
library = factories["music.Library"](actor=actor)
unrelated_library = factories["music.Library"]()
dispatch = mocker.patch.object(routes.outbox, "dispatch")
tasks.delete_account(user_id=user.pk)
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
)
with pytest.raises(user.DoesNotExist):
user.refresh_from_db()
with pytest.raises(library.DoesNotExist):
library.refresh_from_db()
# this one shouldn't be deleted
unrelated_library.refresh_from_db()
actor.refresh_from_db()
assert actor.type == "Tombstone"
assert actor.name is None
assert actor.summary is None

View file

@ -39,7 +39,7 @@ def test_username_only_accepts_letters_and_underscores(
def test_can_restrict_usernames(settings, preferences, db, api_client):
url = reverse("rest_register")
preferences["users__registration_enabled"] = True
settings.USERNAME_BLACKLIST = ["funkwhale"]
settings.ACCOUNT_USERNAME_BLACKLIST = ["funkwhale"]
data = {
"username": "funkwhale",
"email": "contact@funkwhale.io",
@ -333,3 +333,57 @@ def test_creating_user_sends_confirmation_email(
confirmation_message = mailoutbox[-1]
assert "Hello world" in confirmation_message.body
assert settings.FUNKWHALE_HOSTNAME in confirmation_message.body
def test_user_account_deletion_requires_valid_password(logged_in_api_client):
user = logged_in_api_client.user
user.set_password("mypassword")
url = reverse("api:v1:users:users-me")
payload = {"password": "invalid", "confirm": True}
response = logged_in_api_client.delete(url, payload)
assert response.status_code == 400
def test_user_account_deletion_requires_confirmation(logged_in_api_client):
user = logged_in_api_client.user
user.set_password("mypassword")
url = reverse("api:v1:users:users-me")
payload = {"password": "mypassword", "confirm": False}
response = logged_in_api_client.delete(url, payload)
assert response.status_code == 400
def test_user_account_deletion_triggers_delete_account(logged_in_api_client, mocker):
user = logged_in_api_client.user
user.set_password("mypassword")
url = reverse("api:v1:users:users-me")
payload = {"password": "mypassword", "confirm": True}
delete_account = mocker.patch("funkwhale_api.users.tasks.delete_account.delay")
response = logged_in_api_client.delete(url, payload)
assert response.status_code == 204
delete_account.assert_called_once_with(user_id=user.pk)
def test_username_with_existing_local_account_are_invalid(
settings, preferences, factories, api_client
):
actor = factories["users.User"]().create_actor()
user = actor.user
user.delete()
url = reverse("rest_register")
preferences["users__registration_enabled"] = True
settings.ACCOUNT_USERNAME_BLACKLIST = []
data = {
"username": user.username,
"email": "contact@funkwhale.io",
"password1": "testtest",
"password2": "testtest",
}
response = api_client.post(url, data)
assert response.status_code == 400
assert "username" in response.data