Merge branch 'release/0.8'

This commit is contained in:
Eliot Berriot 2018-04-02 20:04:51 +02:00
commit 78d0de0e7d
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
146 changed files with 3652 additions and 934 deletions

View File

@ -1,3 +1,9 @@
API_AUTHENTICATION_REQUIRED=True
RAVEN_ENABLED=false
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
DJANGO_ALLOWED_HOSTS=localhost,nginx
DJANGO_SETTINGS_MODULE=config.settings.local
DJANGO_SECRET_KEY=dev
C_FORCE_ROOT=true
FUNKWHALE_URL=http://localhost
PYTHONDONTWRITEBYTECODE=true

1
.gitignore vendored
View File

@ -86,3 +86,4 @@ front/selenium-debug.log
docs/_build
data/
.env

View File

@ -13,6 +13,7 @@ stages:
test_api:
services:
- postgres:9.4
- redis:3
stage: test
image: funkwhale/funkwhale:latest
cache:
@ -24,6 +25,7 @@ test_api:
DATABASE_URL: "postgresql://postgres@postgres/postgres"
FUNKWHALE_URL: "https://funkwhale.ci"
CACHEOPS_ENABLED: "false"
DJANGO_SETTINGS_MODULE: config.settings.local
before_script:
- cd api
@ -31,7 +33,7 @@ test_api:
- pip install -r requirements/local.txt
- pip install -r requirements/test.txt
script:
- pytest
- pytest --cov=funkwhale_api tests/
tags:
- docker

View File

@ -3,6 +3,100 @@ Changelog
.. towncrier
0.8 (2018-04-02)
----------------
Features:
- Add a detail page for radios (#64)
- Implemented page title binding (#1)
- Previous Track button restart playback after 3 seconds (#146)
Enhancements:
- Added credits to Francis Gading for the logotype (#101)
- API endpoint for fetching instance activity and updated timeline to use this
new endpoint (#141)
- Better error messages in case of missing environment variables (#140)
- Implemented a @test@yourfunkwhaledomain bot to ensure federation works
properly. Send it "/ping" and it will answer back :)
- Queue shuffle now apply only to tracks after the current one (#97)
- Removed player from queue tab and consistently show current track in queue
(#131)
- We now restrict some usernames from being used during signup (#139)
Bugfixes:
- Better error handling during file import (#120)
- Better handling of utf-8 filenames during file import (#138)
- Converted favicon from .ico to .png (#130)
- Upgraded to Python 3.6 to fix weird but harmless weakref error on django task
(#121)
Documentation:
- Documented the upgrade process (#127)
Preparing for federation
^^^^^^^^^^^^^^^^^^^^^^^^
Federation of music libraries is one of the most asked feature.
While there is still a lot of work to do, this version includes
the foundation that will enable funkwhale servers to communicate
between each others, and with other federated software, such as
Mastodon.
Funkwhale will use ActivityPub as it's federation protocol.
In order to prepare for federation (see #136 and #137), new API endpoints
have been added under /federation and /.well-known/webfinger.
For these endpoints to work, you will need to update your nginx configuration,
and add the following snippets::
location /federation/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/federation/;
}
location /.well-known/webfinger {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/.well-known/webfinger;
}
This will ensure federation endpoints will be reachable in the future.
You can of course skip this part if you know you will not federate your instance.
A new ``FEDERATION_ENABLED`` env var have also been added to control wether
federation is enabled or not on the application side. This settings defaults
to True, which should have no consequencies at the moment, since actual
federation is not implemented and the only available endpoints are for
testing purposes.
Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation
on the application side.
The last step involves generating RSA private and public keys for signing
your instance requests on the federation. This can be done via::
# on docker setups
docker-compose run --rm api python manage.py generate_keys --no-input
# on non-docker setups
source /srv/funkwhale/virtualenv/bin/activate
source /srv/funkwhale/load_env
python manage.py generate_keys --no-input
To test and troobleshoot federation, we've added a bot account. This bot is available at @test@yourinstancedomain,
and sending it "/ping", for example, via Mastodon, should trigger
a response.
0.7 (2018-03-21)
----------------

View File

@ -73,6 +73,19 @@ via the following command::
docker-compose -f dev.yml build
Creating your env file
^^^^^^^^^^^^^^^^^^^^^^
We provide a working .env.dev configuration file that is suitable for
development. However, to enable customization on your machine, you should
also create a .env file that will hold your personal environment
variables (those will not be commited to the project).
Create it like this::
touch .env
Database management
^^^^^^^^^^^^^^^^^^^

View File

@ -1,4 +1,4 @@
FROM python:3.5
FROM python:3.6
ENV PYTHONUNBUFFERED 1

View File

@ -1,7 +1,3 @@
#!/bin/bash
set -e
if [ $1 = "pytest" ]; then
# let pytest.ini handle it
unset DJANGO_SETTINGS_MODULE
fi
exec "$@"

View File

@ -1,5 +1,6 @@
from rest_framework import routers
from django.conf.urls import include, url
from funkwhale_api.activity import views as activity_views
from funkwhale_api.instance import views as instance_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
@ -10,6 +11,7 @@ from dynamic_preferences.users.viewsets import UserPreferencesViewSet
router = routers.SimpleRouter()
router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
router.register(r'activity', activity_views.ActivityViewSet, 'activity')
router.register(r'tags', views.TagViewSet, 'tags')
router.register(r'tracks', views.TrackViewSet, 'tracks')
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')

View File

@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
"""
from __future__ import absolute_import, unicode_literals
from urllib.parse import urlsplit
import os
import environ
from funkwhale_api import __version__
@ -24,8 +25,13 @@ try:
except FileNotFoundError:
pass
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
FUNKWHALE_URL = env('FUNKWHALE_URL')
FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# APP CONFIGURATION
# ------------------------------------------------------------------------------
@ -89,6 +95,7 @@ LOCAL_APPS = (
'funkwhale_api.music',
'funkwhale_api.requests',
'funkwhale_api.favorites',
'funkwhale_api.federation',
'funkwhale_api.radios',
'funkwhale_api.history',
'funkwhale_api.playlists',
@ -231,6 +238,7 @@ STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles')))
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = env("STATIC_URL", default='/staticfiles/')
DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage'
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = (
@ -336,7 +344,12 @@ REST_FRAMEWORK = {
),
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
'PAGE_SIZE': 25,
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.FormParser',
'rest_framework.parsers.MultiPartParser',
'funkwhale_api.federation.parsers.ActivityParser',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
@ -385,3 +398,16 @@ CSRF_USE_SESSIONS = True
# Playlist settings
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
ACCOUNT_USERNAME_BLACKLIST = [
'funkwhale',
'library',
'test',
'status',
'root',
'admin',
'owner',
'superuser',
'staff',
'service',
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])

View File

@ -72,6 +72,10 @@ LOGGING = {
'handlers':['console'],
'propagate': True,
'level':'DEBUG',
}
},
'': {
'level': 'DEBUG',
'handlers': ['console'],
},
},
}

View File

@ -1,29 +0,0 @@
from .common import * # noqa
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
# Mail settings
# ------------------------------------------------------------------------------
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend')
# CACHING
# ------------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': ''
}
}
CELERY_BROKER_URL = 'memory://'
########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns
CELERY_TASK_ALWAYS_EAGER = True
########## END CELERY
# Your local stuff: Below this line define 3rd party library settings
API_AUTHENTICATION_REQUIRED = False
CACHEOPS_ENABLED = False

View File

@ -13,6 +13,9 @@ urlpatterns = [
url(settings.ADMIN_URL, admin.site.urls),
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
url(r'^', include(
('funkwhale_api.federation.urls', 'federation'),
namespace="federation")),
url(r'^api/v1/auth/', include('rest_auth.urls')),
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
url(r'^accounts/', include('allauth.urls')),

View File

@ -1,4 +1,4 @@
FROM python:3.5
FROM python:3.6
ENV PYTHONUNBUFFERED 1

View File

@ -1,5 +1,7 @@
from rest_framework import serializers
from funkwhale_api.activity import record
class ModelSerializer(serializers.ModelSerializer):
id = serializers.CharField(source='get_activity_url')
@ -8,3 +10,15 @@ class ModelSerializer(serializers.ModelSerializer):
def get_url(self, obj):
return self.get_id(obj)
class AutoSerializer(serializers.Serializer):
"""
A serializer that will automatically use registered activity serializers
to serialize an henerogeneous list of objects (favorites, listenings, etc.)
"""
def to_representation(self, instance):
serializer = record.registry[instance._meta.label]['serializer'](
instance
)
return serializer.data

View File

@ -0,0 +1,64 @@
from django.db import models
from funkwhale_api.common import fields
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.history.models import Listening
def combined_recent(limit, **kwargs):
datetime_field = kwargs.pop('datetime_field', 'creation_date')
source_querysets = {
qs.model._meta.label: qs for qs in kwargs.pop('querysets')
}
querysets = {
k: qs.annotate(
__type=models.Value(
qs.model._meta.label, output_field=models.CharField()
)
).values('pk', datetime_field, '__type')
for k, qs in source_querysets.items()
}
_qs_list = list(querysets.values())
union_qs = _qs_list[0].union(*_qs_list[1:])
records = []
for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]:
records.append({
'type': row['__type'],
'when': row[datetime_field],
'pk': row['pk']
})
# Now we bulk-load each object type in turn
to_load = {}
for record in records:
to_load.setdefault(record['type'], []).append(record['pk'])
fetched = {}
for key, pks in to_load.items():
for item in source_querysets[key].filter(pk__in=pks):
fetched[(key, item.pk)] = item
# Annotate 'records' with loaded objects
for record in records:
record['object'] = fetched[(record['type'], record['pk'])]
return records
def get_activity(user, limit=20):
query = fields.privacy_level_query(
user, lookup_field='user__privacy_level')
querysets = [
Listening.objects.filter(query).select_related(
'track',
'user',
'track__artist',
'track__album__artist',
),
TrackFavorite.objects.filter(query).select_related(
'track',
'user',
'track__artist',
'track__album__artist',
),
]
records = combined_recent(limit=limit, querysets=querysets)
return [r['object'] for r in records]

View File

@ -0,0 +1,20 @@
from rest_framework import viewsets
from rest_framework.response import Response
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.favorites.models import TrackFavorite
from . import serializers
from . import utils
class ActivityViewSet(viewsets.GenericViewSet):
serializer_class = serializers.AutoSerializer
permission_classes = [ConditionalAuthentication]
queryset = TrackFavorite.objects.none()
def list(self, request, *args, **kwargs):
activity = utils.get_activity(user=request.user)
serializer = self.serializer_class(activity, many=True)
return Response({'results': serializer.data}, status=200)

View File

@ -22,6 +22,6 @@ def privacy_level_query(user, lookup_field='privacy_level'):
return models.Q(**{
'{}__in'.format(lookup_field): [
'me', 'followers', 'instance', 'everyone'
'followers', 'instance', 'everyone'
]
})

View File

@ -0,0 +1,12 @@
import unicodedata
from django.core.files.storage import FileSystemStorage
class ASCIIFileSystemStorage(FileSystemStorage):
"""
Convert unicode characters in name to ASCII characters.
"""
def get_valid_name(self, name):
name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore')
return super().get_valid_name(name)

View File

View File

@ -0,0 +1,85 @@
import logging
import json
import requests
import requests_http_signature
from . import signing
logger = logging.getLogger(__name__)
ACTIVITY_TYPES = [
'Accept',
'Add',
'Announce',
'Arrive',
'Block',
'Create',
'Delete',
'Dislike',
'Flag',
'Follow',
'Ignore',
'Invite',
'Join',
'Leave',
'Like',
'Listen',
'Move',
'Offer',
'Question',
'Reject',
'Read',
'Remove',
'TentativeReject',
'TentativeAccept',
'Travel',
'Undo',
'Update',
'View',
]
OBJECT_TYPES = [
'Article',
'Audio',
'Document',
'Event',
'Image',
'Note',
'Page',
'Place',
'Profile',
'Relationship',
'Tombstone',
'Video',
]
def deliver(activity, on_behalf_of, to=[]):
from . import actors
logger.info('Preparing activity delivery to %s', to)
auth = requests_http_signature.HTTPSignatureAuth(
use_auth_header=False,
headers=[
'(request-target)',
'user-agent',
'host',
'date',
'content-type',],
algorithm='rsa-sha256',
key=on_behalf_of.private_key.encode('utf-8'),
key_id=on_behalf_of.private_key_id,
)
for url in to:
recipient_actor = actors.get_actor(url)
logger.debug('delivering to %s', recipient_actor.inbox_url)
logger.debug('activity content: %s', json.dumps(activity))
response = requests.post(
auth=auth,
json=activity,
url=recipient_actor.inbox_url,
headers={
'Content-Type': 'application/activity+json'
}
)
response.raise_for_status()
logger.debug('Remote answered with %s', response.status_code)

View File

@ -0,0 +1,220 @@
import logging
import requests
import xml
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.exceptions import PermissionDenied
from dynamic_preferences.registries import global_preferences_registry
from . import activity
from . import models
from . import serializers
from . import utils
logger = logging.getLogger(__name__)
def remove_tags(text):
logger.debug('Removing tags from %s', text)
return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())
def get_actor_data(actor_url):
response = requests.get(
actor_url,
headers={
'Accept': 'application/activity+json',
}
)
response.raise_for_status()
try:
return response.json()
except:
raise ValueError(
'Invalid actor payload: {}'.format(response.text))
def get_actor(actor_url):
data = get_actor_data(actor_url)
serializer = serializers.ActorSerializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer.build()
class SystemActor(object):
additional_attributes = {}
def get_actor_instance(self):
a = models.Actor(
**self.get_instance_argument(
self.id,
name=self.name,
summary=self.summary,
**self.additional_attributes
)
)
a.pk = self.id
return a
def get_instance_argument(self, id, name, summary, **kwargs):
preferences = global_preferences_registry.manager()
p = {
'preferred_username': id,
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': name.format(host=settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': id})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': id})),
'public_key': preferences['federation__public_key'],
'private_key': preferences['federation__private_key'],
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
}
p.update(kwargs)
return p
def get_inbox(self, data, actor=None):
raise NotImplementedError
def post_inbox(self, data, actor=None):
raise NotImplementedError
def get_outbox(self, data, actor=None):
raise NotImplementedError
def post_outbox(self, data, actor=None):
raise NotImplementedError
class LibraryActor(SystemActor):
id = 'library'
name = '{host}\'s library'
summary = 'Bot account to federate with {host}\'s library'
additional_attributes = {
'manually_approves_followers': True
}
class TestActor(SystemActor):
id = 'test'
name = '{host}\'s test account'
summary = (
'Bot account to test federation with {host}. '
'Send me /ping and I\'ll answer you.'
)
additional_attributes = {
'manually_approves_followers': False
}
def get_outbox(self, data, actor=None):
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': self.id})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
def post_inbox(self, data, actor=None):
if actor is None:
raise PermissionDenied('Actor not authenticated')
serializer = serializers.ActivitySerializer(
data=data, context={'actor': actor})
serializer.is_valid(raise_exception=True)
ac = serializer.validated_data
logger.info('Received activity on %s inbox', self.id)
if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
# we received a toot \o/
command = self.parse_command(ac['object']['content'])
logger.debug('Parsed command: %s', command)
if command == 'ping':
self.handle_ping(ac, actor)
def parse_command(self, message):
"""
Remove any links or fancy markup to extract /command from
a note message.
"""
raw = remove_tags(message)
try:
return raw.split('/')[1]
except IndexError:
return
def handle_ping(self, ac, sender):
now = timezone.now()
test_actor = self.get_actor_instance()
reply_url = 'https://{}/activities/note/{}'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
)
reply_content = '{} Pong!'.format(
sender.mention_username
)
reply_activity = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
'type': 'Create',
'actor': test_actor.url,
'id': '{}/activity'.format(reply_url),
'published': now.isoformat(),
'to': ac['actor'],
'cc': [],
'object': {
'type': 'Note',
'content': 'Pong!',
'summary': None,
'published': now.isoformat(),
'id': reply_url,
'inReplyTo': ac['object']['id'],
'sensitive': False,
'url': reply_url,
'to': [ac['actor']],
'attributedTo': test_actor.url,
'cc': [],
'attachment': [],
'tag': [{
"type": "Mention",
"href": ac['actor'],
"name": sender.mention_username
}]
}
}
activity.deliver(
reply_activity,
to=[ac['actor']],
on_behalf_of=test_actor)
SYSTEM_ACTORS = {
'library': LibraryActor(),
'test': TestActor(),
}

View File

@ -0,0 +1,52 @@
import cryptography
from django.contrib.auth.models import AnonymousUser
from rest_framework import authentication
from rest_framework import exceptions
from . import actors
from . import keys
from . import serializers
from . import signing
from . import utils
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate_actor(self, request):
headers = utils.clean_wsgi_headers(request.META)
try:
signature = headers['Signature']
key_id = keys.get_key_id_from_signature_header(signature)
except KeyError:
return
except ValueError as e:
raise exceptions.AuthenticationFailed(str(e))
try:
actor_data = actors.get_actor_data(key_id)
except Exception as e:
raise exceptions.AuthenticationFailed(str(e))
try:
public_key = actor_data['publicKey']['publicKeyPem']
except KeyError:
raise exceptions.AuthenticationFailed('No public key found')
serializer = serializers.ActorSerializer(data=actor_data)
if not serializer.is_valid():
raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors))
try:
signing.verify_django(request, public_key.encode('utf-8'))
except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed('Invalid signature')
return serializer.build()
def authenticate(self, request):
setattr(request, 'actor', None)
actor = self.authenticate_actor(request)
user = AnonymousUser()
setattr(request, 'actor', actor)
return (user, None)

View File

@ -0,0 +1,34 @@
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
federation = types.Section('federation')
@global_preferences_registry.register
class FederationPrivateKey(types.StringPreference):
show_in_api = False
section = federation
name = 'private_key'
default = ''
help_text = (
'Instance private key, used for signing federation HTTP requests'
)
verbose_name = (
'Instance private key (keep it secret, do not change it)'
)
@global_preferences_registry.register
class FederationPublicKey(types.StringPreference):
show_in_api = False
section = federation
name = 'public_key'
default = ''
help_text = (
'Instance public key, used for signing federation HTTP requests'
)
verbose_name = (
'Instance public key (do not change it)'
)

View File

@ -0,0 +1,8 @@
class MalformedPayload(ValueError):
pass
class MissingSignature(KeyError):
pass

View File

@ -0,0 +1,91 @@
import factory
import requests
import requests_http_signature
from django.utils import timezone
from funkwhale_api.factories import registry
from . import keys
from . import models
registry.register(keys.get_key_pair, name='federation.KeyPair')
@registry.register(name='federation.SignatureAuth')
class SignatureAuthFactory(factory.Factory):
algorithm = 'rsa-sha256'
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker('url')
use_auth_header = False
headers = [
'(request-target)',
'user-agent',
'host',
'date',
'content-type',]
class Meta:
model = requests_http_signature.HTTPSignatureAuth
@registry.register(name='federation.SignedRequest')
class SignedRequestFactory(factory.Factory):
url = factory.Faker('url')
method = 'get'
auth = factory.SubFactory(SignatureAuthFactory)
class Meta:
model = requests.Request
@factory.post_generation
def headers(self, create, extracted, **kwargs):
default_headers = {
'User-Agent': 'Test',
'Host': 'test.host',
'Date': 'Right now',
'Content-Type': 'application/activity+json'
}
if extracted:
default_headers.update(extracted)
self.headers.update(default_headers)
@registry.register
class ActorFactory(factory.DjangoModelFactory):
public_key = None
private_key = None
preferred_username = factory.Faker('user_name')
summary = factory.Faker('paragraph')
domain = factory.Faker('domain_name')
url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
class Meta:
model = models.Actor
@classmethod
def _generate(cls, create, attrs):
has_public = attrs.get('public_key') is not None
has_private = attrs.get('private_key') is not None
if not has_public and not has_private:
private, public = keys.get_key_pair()
attrs['private_key'] = private.decode('utf-8')
attrs['public_key'] = public.decode('utf-8')
return super()._generate(create, attrs)
@registry.register(name='federation.Note')
class NoteFactory(factory.Factory):
type = 'Note'
id = factory.Faker('url')
published = factory.LazyFunction(
lambda: timezone.now().isoformat()
)
inReplyTo = None
content = factory.Faker('sentence')
class Meta:
model = dict

View File

@ -0,0 +1,49 @@
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend
import re
import requests
import urllib.parse
from . import exceptions
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
def get_key_pair(size=2048):
key = rsa.generate_private_key(
backend=crypto_default_backend(),
public_exponent=65537,
key_size=size
)
private_key = key.private_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PrivateFormat.PKCS8,
crypto_serialization.NoEncryption())
public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.PKCS1
)
return private_key, public_key
def get_key_id_from_signature_header(header_string):
parts = header_string.split(',')
try:
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
except IndexError:
raise ValueError('Missing key id')
match = KEY_ID_REGEX.match(raw_key_id)
if not match:
raise ValueError('Invalid key id')
key_id = match.groups()[0]
url = urllib.parse.urlparse(key_id)
if not url.scheme or not url.netloc:
raise ValueError('Invalid url')
if url.scheme not in ['http', 'https']:
raise ValueError('Invalid shceme')
return key_id

View File

@ -0,0 +1,53 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.federation import keys
class Command(BaseCommand):
help = (
'Generate a public/private key pair for your instance,'
' for federation purposes. If a key pair already exists, does nothing.'
)
def add_arguments(self, parser):
parser.add_argument(
'--replace',
action='store_true',
dest='replace',
default=False,
help='Replace existing key pair, if any',
)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
)
@transaction.atomic
def handle(self, *args, **options):
preferences = global_preferences_registry.manager()
existing_public = preferences['federation__public_key']
existing_private = preferences['federation__public_key']
if existing_public or existing_private and not options['replace']:
raise CommandError(
'Keys are already present! '
'Replace them with --replace if you know what you are doing.')
if options['interactive']:
message = (
'Are you sure you want to do this?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
)
if input(''.join(message)) != 'yes':
raise CommandError("Operation cancelled.")
private, public = keys.get_key_pair()
preferences['federation__public_key'] = public.decode('utf-8')
preferences['federation__private_key'] = private.decode('utf-8')
self.stdout.write(
'Your new key pair was generated.'
'Your public key is now:\n\n{}'.format(public.decode('utf-8'))
)

View File

@ -0,0 +1,37 @@
# Generated by Django 2.0.3 on 2018-03-31 13:43
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Actor',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(db_index=True, max_length=500, unique=True)),
('outbox_url', models.URLField(max_length=500)),
('inbox_url', models.URLField(max_length=500)),
('following_url', models.URLField(blank=True, max_length=500, null=True)),
('followers_url', models.URLField(blank=True, max_length=500, null=True)),
('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('domain', models.CharField(max_length=1000)),
('summary', models.CharField(blank=True, max_length=500, null=True)),
('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
('public_key', models.CharField(blank=True, max_length=5000, null=True)),
('private_key', models.CharField(blank=True, max_length=5000, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
('manually_approves_followers', models.NullBooleanField(default=None)),
],
),
]

View File

@ -0,0 +1,59 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
TYPE_CHOICES = [
('Person', 'Person'),
('Application', 'Application'),
('Group', 'Group'),
('Organization', 'Organization'),
('Service', 'Service'),
]
class Actor(models.Model):
url = models.URLField(unique=True, max_length=500, db_index=True)
outbox_url = models.URLField(max_length=500)
inbox_url = models.URLField(max_length=500)
following_url = models.URLField(max_length=500, null=True, blank=True)
followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField(
choices=TYPE_CHOICES, default='Person', max_length=25)
name = models.CharField(max_length=200, null=True, blank=True)
domain = models.CharField(max_length=1000)
summary = models.CharField(max_length=500, null=True, blank=True)
preferred_username = models.CharField(
max_length=200, null=True, blank=True)
public_key = models.CharField(max_length=5000, null=True, blank=True)
private_key = models.CharField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField(
default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None)
@property
def webfinger_subject(self):
return '{}@{}'.format(
self.preferred_username,
settings.FEDERATION_HOSTNAME,
)
@property
def private_key_id(self):
return '{}#main-key'.format(self.url)
@property
def mention_username(self):
return '@{}@{}'.format(self.preferred_username, self.domain)
def save(self, **kwargs):
lowercase_fields = [
'domain',
]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)

View File

@ -0,0 +1,5 @@
from rest_framework import parsers
class ActivityParser(parsers.JSONParser):
media_type = 'application/activity+json'

View File

@ -0,0 +1,9 @@
from rest_framework.renderers import JSONRenderer
class ActivityPubRenderer(JSONRenderer):
media_type = 'application/activity+json'
class WebfingerRenderer(JSONRenderer):
media_type = 'application/jrd+json'

View File

@ -0,0 +1,175 @@
import urllib.parse
from django.urls import reverse
from django.conf import settings
from rest_framework import serializers
from dynamic_preferences.registries import global_preferences_registry
from . import activity
from . import models
from . import utils
class ActorSerializer(serializers.ModelSerializer):
# left maps to activitypub fields, right to our internal models
id = serializers.URLField(source='url')
outbox = serializers.URLField(source='outbox_url')
inbox = serializers.URLField(source='inbox_url')
following = serializers.URLField(source='following_url', required=False)
followers = serializers.URLField(source='followers_url', required=False)
preferredUsername = serializers.CharField(
source='preferred_username', required=False)
publicKey = serializers.JSONField(source='public_key', required=False)
manuallyApprovesFollowers = serializers.NullBooleanField(
source='manually_approves_followers', required=False)
summary = serializers.CharField(max_length=None, required=False)
class Meta:
model = models.Actor
fields = [
'id',
'type',
'name',
'summary',
'preferredUsername',
'publicKey',
'inbox',
'outbox',
'following',
'followers',
'manuallyApprovesFollowers',
]
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{},
]
if instance.public_key:
ret['publicKey'] = {
'owner': instance.url,
'publicKeyPem': instance.public_key,
'id': '{}#main-key'.format(instance.url)
}
ret['endpoints'] = {}
if instance.shared_inbox_url:
ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
return ret
def prepare_missing_fields(self):
kwargs = {}
domain = urllib.parse.urlparse(self.validated_data['url']).netloc
kwargs['domain'] = domain
for endpoint, url in self.initial_data.get('endpoints', {}).items():
if endpoint == 'sharedInbox':
kwargs['shared_inbox_url'] = url
break
try:
kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem']
except KeyError:
pass
return kwargs
def build(self):
d = self.validated_data.copy()
d.update(self.prepare_missing_fields())
return self.Meta.model(**d)
def save(self, **kwargs):
kwargs.update(self.prepare_missing_fields())
return super().save(**kwargs)
def validate_summary(self, value):
if value:
return value[:500]
class ActorWebfingerSerializer(serializers.ModelSerializer):
class Meta:
model = models.Actor
fields = ['url']
def to_representation(self, instance):
data = {}
data['subject'] = 'acct:{}'.format(instance.webfinger_subject)
data['links'] = [
{
'rel': 'self',
'href': instance.url,
'type': 'application/activity+json'
}
]
data['aliases'] = [
instance.url
]
return data
class ActivitySerializer(serializers.Serializer):
actor = serializers.URLField()
id = serializers.URLField()
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.ACTIVITY_TYPES])
object = serializers.JSONField()
def validate_object(self, value):
try:
type = value['type']
except KeyError:
raise serializers.ValidationError('Missing object type')
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(
'Unsupported type {}'.format(type))
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
def validate_actor(self, value):
request_actor = self.context.get('actor')
if request_actor and request_actor.url != value:
raise serializers.ValidationError(
'The actor making the request do not match'
' the activity actor'
)
return value
class ObjectSerializer(serializers.Serializer):
id = serializers.URLField()
url = serializers.URLField(required=False, allow_null=True)
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.OBJECT_TYPES])
content = serializers.CharField(
required=False, allow_null=True)
summary = serializers.CharField(
required=False, allow_null=True)
name = serializers.CharField(
required=False, allow_null=True)
published = serializers.DateTimeField(
required=False, allow_null=True)
updated = serializers.DateTimeField(
required=False, allow_null=True)
to = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
cc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bto = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bcc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
OBJECT_SERIALIZERS = {
t: ObjectSerializer
for t in activity.OBJECT_TYPES
}

View File

@ -0,0 +1,55 @@
import logging
import requests
import requests_http_signature
from . import exceptions
from . import utils
logger = logging.getLogger(__name__)
def verify(request, public_key):
return requests_http_signature.HTTPSignatureAuth.verify(
request,
key_resolver=lambda **kwargs: public_key,
use_auth_header=False,
)
def verify_django(django_request, public_key):
"""
Given a django WSGI request, create an underlying requests.PreparedRequest
instance we can verify
"""
headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility
# with requests_http_signature
headers[h.lower()] = v
try:
signature = headers['Signature']
except KeyError:
raise exceptions.MissingSignature
url = 'http://noop{}'.format(django_request.path)
query = django_request.META['QUERY_STRING']
if query:
url += '?{}'.format(query)
signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(' ')
logger.debug('Signature expected headers: %s', expected)
for header in expected:
try:
headers[header]
except KeyError:
logger.debug('Missing header: %s', header)
request = requests.Request(
method=django_request.method,
url=url,
data=django_request.body,
headers=headers)
for h in request.headers.keys():
v = request.headers[h]
if v:
request.headers[h] = str(v)
prepared_request = request.prepare()
return verify(request, public_key)

View File

@ -0,0 +1,15 @@
from rest_framework import routers
from . import views
router = routers.SimpleRouter(trailing_slash=False)
router.register(
r'federation/instance/actors',
views.InstanceActorViewSet,
'instance-actors')
router.register(
r'.well-known',
views.WellKnownViewSet,
'well-known')
urlpatterns = router.urls

View File

@ -0,0 +1,35 @@
from django.conf import settings
def full_url(path):
"""
Given a relative path, return a full url usable for federation purpose
"""
root = settings.FUNKWHALE_URL
if path.startswith('/') and root.endswith('/'):
return root + path[1:]
elif not path.startswith('/') and not root.endswith('/'):
return root + '/' + path
else:
return root + path
def clean_wsgi_headers(raw_headers):
"""
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
"""
cleaned = {}
non_prefixed = [
'content_type',
'content_length',
]
for raw_header, value in raw_headers.items():
h = raw_header.lower()
if not h.startswith('http_') and h not in non_prefixed:
continue
words = h.replace('http_', '', 1).split('_')
cleaned_header = '-'.join([w.capitalize() for w in words])
cleaned[cleaned_header] = value
return cleaned

View File

@ -0,0 +1,103 @@
from django import forms
from django.conf import settings
from django.http import HttpResponse
from rest_framework import viewsets
from rest_framework import views
from rest_framework import response
from rest_framework.decorators import list_route, detail_route
from . import actors
from . import authentication
from . import renderers
from . import serializers
from . import webfinger
class FederationMixin(object):
def dispatch(self, request, *args, **kwargs):
if not settings.FEDERATION_ENABLED:
return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs)
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
lookup_field = 'actor'
lookup_value_regex = '[a-z]*'
authentication_classes = [
authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer]
def get_object(self):
try:
return actors.SYSTEM_ACTORS[self.kwargs['actor']]
except KeyError:
raise Http404
def retrieve(self, request, *args, **kwargs):
system_actor = self.get_object()
actor = system_actor.get_actor_instance()
serializer = serializers.ActorSerializer(actor)
return response.Response(serializer.data, status=200)
@detail_route(methods=['get', 'post'])
def inbox(self, request, *args, **kwargs):
system_actor = self.get_object()
handler = getattr(system_actor, '{}_inbox'.format(
request.method.lower()
))
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
@detail_route(methods=['get', 'post'])
def outbox(self, request, *args, **kwargs):
system_actor = self.get_object()
handler = getattr(system_actor, '{}_outbox'.format(
request.method.lower()
))
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
renderer_classes = [renderers.WebfingerRenderer]
@list_route(methods=['get'])
def webfinger(self, request, *args, **kwargs):
try:
resource_type, resource = webfinger.clean_resource(
request.GET['resource'])
cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
result = cleaner(resource)
except forms.ValidationError as e:
return response.Response({
'errors': {
'resource': e.message
}
}, status=400)
except KeyError:
return response.Response({
'errors': {
'resource': 'This field is required',
}
}, status=400)
handler = getattr(self, 'handler_{}'.format(resource_type))
data = handler(result)
return response.Response(data)
def handler_acct(self, clean_result):
username, hostname = clean_result
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
return serializers.ActorWebfingerSerializer(actor).data

View File

@ -0,0 +1,39 @@
from django import forms
from django.conf import settings
from django.urls import reverse
from . import actors
from . import utils
VALID_RESOURCE_TYPES = ['acct']
def clean_resource(resource_string):
if not resource_string:
raise forms.ValidationError('Invalid resource string')
try:
resource_type, resource = resource_string.split(':', 1)
except ValueError:
raise forms.ValidationError('Missing webfinger resource type')
if resource_type not in VALID_RESOURCE_TYPES:
raise forms.ValidationError('Invalid webfinger resource type')
return resource_type, resource
def clean_acct(acct_string):
try:
username, hostname = acct_string.split('@')
except ValueError:
raise forms.ValidationError('Invalid format')
if hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError(
'Invalid hostname {}'.format(hostname))
if username not in actors.SYSTEM_ACTORS:
raise forms.ValidationError('Invalid username')
return username, hostname

View File

@ -4,7 +4,7 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ['track', 'end_date', 'user', 'session_key']
list_display = ['track', 'creation_date', 'user', 'session_key']
search_fields = ['track__name', 'user__username']
list_select_related = [
'user',

View File

@ -0,0 +1,22 @@
# Generated by Django 2.0.3 on 2018-03-25 14:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('history', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='listening',
options={'ordering': ('-creation_date',)},
),
migrations.RenameField(
model_name='listening',
old_name='end_date',
new_name='creation_date',
),
]

View File

@ -6,7 +6,8 @@ from funkwhale_api.music.models import Track
class Listening(models.Model):
end_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
creation_date = models.DateTimeField(
default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE)
user = models.ForeignKey(
@ -18,7 +19,7 @@ class Listening(models.Model):
session_key = models.CharField(max_length=100, null=True, blank=True)
class Meta:
ordering = ('-end_date',)
ordering = ('-creation_date',)
def save(self, **kwargs):
if not self.user and not self.session_key:

View File

@ -12,7 +12,7 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track')
actor = UserActivitySerializer(source='user')
published = serializers.DateTimeField(source='end_date')
published = serializers.DateTimeField(source='creation_date')
class Meta:
model = models.Listening
@ -36,7 +36,7 @@ class ListeningSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ('id', 'user', 'session_key', 'track', 'end_date')
fields = ('id', 'user', 'session_key', 'track', 'creation_date')
def create(self, validated_data):

View File

@ -121,7 +121,13 @@ class Metadata(object):
def __init__(self, path):
self._file = mutagen.File(path)
self._conf = CONF[self.get_file_type(self._file)]
if self._file is None:
raise ValueError('Cannot parse metadata from {}'.format(path))
ft = self.get_file_type(self._file)
try:
self._conf = CONF[ft]
except KeyError:
raise ValueError('Unsupported format {}'.format(ft))
def get_file_type(self, f):
return f.__class__.__name__

View File

@ -328,7 +328,7 @@ class SubmitViewSet(viewsets.ViewSet):
job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url'])
tasks.import_job_run.delay(import_job_id=job.pk)
serializer = serializers.ImportBatchSerializer(batch)
return Response(serializer.data)
return Response(serializer.data, status=201)
def get_import_request(self, data):
try:

View File

@ -34,6 +34,13 @@ class Command(BaseCommand):
default=False,
help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI',
)
parser.add_argument(
'--exit', '-x',
action='store_true',
dest='exit_on_failure',
default=False,
help='use this flag to disable error catching',
)
parser.add_argument(
'--no-acoustid',
action='store_true',
@ -106,20 +113,27 @@ class Command(BaseCommand):
async = options['async']
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
for path in matching:
job = batch.jobs.create(
source='file://' + path,
)
name = os.path.basename(path)
with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
job.save()
try:
utils.on_commit(
import_handler,
import_job_id=job.pk,
use_acoustid=not options['no_acoustid'])
self.stdout.write(message.format(path))
self.import_file(path, batch, import_handler, options)
except Exception as e:
self.stdout.write('Error: {}'.format(e))
if options['exit_on_failure']:
raise
m = 'Error while importing {}: {} {}'.format(
path, e.__class__.__name__, e)
self.stderr.write(m)
return batch
def import_file(self, path, batch, import_handler, options):
job = batch.jobs.create(
source='file://' + path,
)
name = os.path.basename(path)
with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
job.save()
utils.on_commit(
import_handler,
import_job_id=job.pk,
use_acoustid=not options['no_acoustid'])

View File

@ -1,6 +1,7 @@
from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.users.serializers import UserBasicSerializer
from . import filters
from . import models
@ -15,6 +16,8 @@ class FilterSerializer(serializers.Serializer):
class RadioSerializer(serializers.ModelSerializer):
user = UserBasicSerializer(read_only=True)
class Meta:
model = models.Radio
fields = (

View File

@ -20,6 +20,7 @@ class RadioViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.RadioSerializer
@ -40,6 +41,16 @@ class RadioViewSet(
raise Http404
return serializer.save(user=self.request.user)
@detail_route(methods=['get'])
def tracks(self, request, *args, **kwargs):
radio = self.get_object()
tracks = radio.get_candidates().for_nested_serialization()
page = self.paginate_queryset(tracks)
if page is not None:
serializer = TrackSerializerNested(page, many=True)
return self.get_paginated_response(serializer.data)
@list_route(methods=['get'])
def filters(self, request, *args, **kwargs):
serializer = serializers.FilterSerializer(

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python
import django
import os
import sys
@ -7,6 +8,12 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
# we're doing this here since otherwise, missing environment
# files in settings result in AttributeError being raised, generating
# a cryptic django.core.exceptions.AppRegistryNotReady error.
# To prevent that, we explicitely load settings here before anything
# else, so we fail fast with a relevant error. See #140 for more details.
django.setup()
from django.core.management import execute_from_command_line

View File

@ -1,3 +1,4 @@
# This file is here because many Platforms as a Service look for
# requirements.txt in the root directory of a project.
-r requirements/base.txt
-r requirements/production.txt

View File

@ -14,7 +14,7 @@ django-allauth>=0.34,<0.35
# Python-PostgreSQL Database Adapter
psycopg2>=2.7,<=2.8
psycopg2-binary>=2.7,<=2.8
# Time zones support
pytz==2017.3
@ -60,3 +60,7 @@ channels_redis>=2.1,<2.2
django-cacheops>=4,<4.1
daphne==2.0.4
cryptography>=2,<3
# requests-http-signature==0.0.3
# clone until the branch is merged and released upstream
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support

View File

@ -9,3 +9,6 @@ git+https://github.com/pytest-dev/pytest-django.git@d3d9bb3ef6f0377cb5356eb36899
pytest-mock
pytest-sugar
pytest-xdist
pytest-cov
pytest-env
requests-mock

View File

@ -7,6 +7,12 @@ max-line-length = 120
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
[tool:pytest]
DJANGO_SETTINGS_MODULE=config.settings.test
python_files = tests.py test_*.py *_tests.py
testpaths = tests
env =
SECRET_KEY=test
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
CELERY_BROKER_URL=memory://
CELERY_TASK_ALWAYS_EAGER=True
CACHEOPS_ENABLED=False
FEDERATION_HOSTNAME=test.federation

View File

View File

@ -0,0 +1,17 @@
from funkwhale_api.activity import serializers
from funkwhale_api.favorites.serializers import TrackFavoriteActivitySerializer
from funkwhale_api.history.serializers import \
ListeningActivitySerializer
def test_autoserializer(factories):
favorite = factories['favorites.TrackFavorite']()
listening = factories['history.Listening']()
objects = [favorite, listening]
serializer = serializers.AutoSerializer(objects, many=True)
expected = [
TrackFavoriteActivitySerializer(favorite).data,
ListeningActivitySerializer(listening).data,
]
assert serializer.data == expected

View File

@ -0,0 +1,21 @@
from funkwhale_api.activity import utils
def test_get_activity(factories):
user = factories['users.User']()
listening = factories['history.Listening']()
favorite = factories['favorites.TrackFavorite']()
objects = list(utils.get_activity(user))
assert objects == [favorite, listening]
def test_get_activity_honors_privacy_level(factories, anonymous_user):
listening = factories['history.Listening'](user__privacy_level='me')
favorite1 = factories['favorites.TrackFavorite'](
user__privacy_level='everyone')
favorite2 = factories['favorites.TrackFavorite'](
user__privacy_level='instance')
objects = list(utils.get_activity(anonymous_user))
assert objects == [favorite1]

View File

@ -0,0 +1,18 @@
from django.urls import reverse
from funkwhale_api.activity import serializers
from funkwhale_api.activity import utils
def test_activity_view(factories, api_client, settings, anonymous_user):
settings.API_AUTHENTICATION_REQUIRED = False
favorite = factories['favorites.TrackFavorite'](
user__privacy_level='everyone')
listening = factories['history.Listening']()
url = reverse('api:v1:activity-list')
objects = utils.get_activity(anonymous_user)
serializer = serializers.AutoSerializer(objects, many=True)
response = api_client.get(url)
assert response.status_code == 200
assert response.data['results'] == serializer.data

View File

View File

View File

@ -10,7 +10,7 @@ from funkwhale_api.users.factories import UserFactory
@pytest.mark.parametrize('user,expected', [
(AnonymousUser(), Q(privacy_level='everyone')),
(UserFactory.build(pk=1),
Q(privacy_level__in=['me', 'followers', 'instance', 'everyone'])),
Q(privacy_level__in=['followers', 'instance', 'everyone'])),
])
def test_privacy_level_query(user,expected):
query = fields.privacy_level_query(user)

View File

@ -2,7 +2,6 @@ import pytest
from rest_framework.views import APIView
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from funkwhale_api.common import permissions
@ -19,24 +18,26 @@ def test_owner_permission_owner_field_ok(nodb_factories, api_request):
assert check is True
def test_owner_permission_owner_field_not_ok(nodb_factories, api_request):
def test_owner_permission_owner_field_not_ok(
anonymous_user, nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
permission = permissions.OwnerPermission()
request = api_request.get('/')
setattr(request, 'user', AnonymousUser())
setattr(request, 'user', anonymous_user)
with pytest.raises(Http404):
permission.has_object_permission(request, view, playlist)
def test_owner_permission_read_only(nodb_factories, api_request):
def test_owner_permission_read_only(
anonymous_user, nodb_factories, api_request):
playlist = nodb_factories['playlists.Playlist']()
view = APIView.as_view()
setattr(view, 'owner_checks', ['write'])
permission = permissions.OwnerPermission()
request = api_request.get('/')
setattr(request, 'user', AnonymousUser())
setattr(request, 'user', anonymous_user)
check = permission.has_object_permission(request, view, playlist)
assert check is True

View File

@ -1,9 +1,13 @@
import factory
import tempfile
import shutil
import pytest
import requests_mock
import shutil
import tempfile
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.test import client
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
@ -31,7 +35,11 @@ def cache():
def factories(db):
from funkwhale_api import factories
for v in factories.registry.values():
v._meta.strategy = factory.CREATE_STRATEGY
try:
v._meta.strategy = factory.CREATE_STRATEGY
except AttributeError:
# probably not a class based factory
pass
yield factories.registry
@ -39,12 +47,16 @@ def factories(db):
def nodb_factories():
from funkwhale_api import factories
for v in factories.registry.values():
v._meta.strategy = factory.BUILD_STRATEGY
try:
v._meta.strategy = factory.BUILD_STRATEGY
except AttributeError:
# probably not a class based factory
pass
yield factories.registry
@pytest.fixture
def preferences(db):
def preferences(db, cache):
manager = global_preferences_registry.manager()
manager.all()
yield manager
@ -66,6 +78,11 @@ def logged_in_client(db, factories, client):
delattr(client, 'user')
@pytest.fixture
def anonymous_user():
return AnonymousUser()
@pytest.fixture
def api_client(client):
return APIClient()
@ -103,6 +120,11 @@ def api_request():
return APIRequestFactory()
@pytest.fixture
def fake_request():
return client.RequestFactory()
@pytest.fixture
def activity_registry():
r = record.registry
@ -126,3 +148,17 @@ def activity_registry():
@pytest.fixture
def activity_muted(activity_registry, mocker):
yield mocker.patch.object(record, 'send')
@pytest.fixture(autouse=True)
def media_root(settings):
tmp_dir = tempfile.mkdtemp()
settings.MEDIA_ROOT = tmp_dir
yield settings.MEDIA_ROOT
shutil.rmtree(tmp_dir)
@pytest.fixture
def r_mock():
with requests_mock.mock() as m:
yield m

View File

View File

View File

@ -0,0 +1,10 @@
import pytest
@pytest.fixture
def authenticated_actor(nodb_factories, mocker):
actor = nodb_factories['federation.Actor']()
mocker.patch(
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
return_value=actor)
yield actor

View File

@ -0,0 +1,32 @@
from funkwhale_api.federation import activity
def test_deliver(nodb_factories, r_mock, mocker):
to = nodb_factories['federation.Actor']()
mocker.patch(
'funkwhale_api.federation.actors.get_actor',
return_value=to)
sender = nodb_factories['federation.Actor']()
ac = {
'id': 'http://test.federation/activity',
'type': 'Create',
'actor': sender.url,
'object': {
'id': 'http://test.federation/note',
'type': 'Note',
'content': 'Hello',
}
}
r_mock.post(to.inbox_url)
activity.deliver(
ac,
to=[to.url],
on_behalf_of=sender,
)
request = r_mock.request_history[0]
assert r_mock.called is True
assert r_mock.call_count == 1
assert request.url == to.inbox_url
assert request.headers['content-type'] == 'application/activity+json'

View File

@ -0,0 +1,190 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.federation import actors
from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils
def test_actor_fetching(r_mock):
payload = {
'id': 'https://actor.mock/users/actor#main-key',
'owner': 'test',
'publicKeyPem': 'test_pem',
}
actor_url = 'https://actor.mock/'
r_mock.get(actor_url, json=payload)
r = actors.get_actor_data(actor_url)
assert r == payload
def test_get_library(settings, preferences):
preferences['federation__public_key'] = 'public_key'
expected = {
'preferred_username': 'library',
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'library'})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'library'})),
'public_key': 'public_key',
'summary': 'Bot account to federate with {}\'s library'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_get_test(settings, preferences):
preferences['federation__public_key'] = 'public_key'
expected = {
'preferred_username': 'test',
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': False,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'test'})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
'public_key': 'public_key',
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_test_get_outbox():
expected = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
assert data == expected
def test_test_post_inbox_requires_authenticated_actor():
with pytest.raises(exceptions.PermissionDenied):
actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
def test_test_post_outbox_validates_actor(nodb_factories):
actor = nodb_factories['federation.Actor']()
data = {
'actor': 'noop'
}
with pytest.raises(exceptions.ValidationError) as exc_info:
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
msg = 'The actor making the request do not match'
assert msg in exc_info.value
def test_test_post_outbox_handles_create_note(
settings, mocker, factories):
deliver = mocker.patch(
'funkwhale_api.federation.activity.deliver')
actor = factories['federation.Actor']()
now = timezone.now()
mocker.patch('django.utils.timezone.now', return_value=now)
data = {
'actor': actor.url,
'type': 'Create',
'id': 'http://test.federation/activity',
'object': {
'type': 'Note',
'id': 'http://test.federation/object',
'content': '<p><a>@mention</a> /ping</p>'
}
}
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
expected_note = factories['federation.Note'](
id='https://test.federation/activities/note/{}'.format(
now.timestamp()
),
content='Pong!',
published=now.isoformat(),
inReplyTo=data['object']['id'],
cc=[],
summary=None,
sensitive=False,
attributedTo=test_actor.url,
attachment=[],
to=[actor.url],
url='https://{}/activities/note/{}'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
tag=[{
'href': actor.url,
'name': actor.mention_username,
'type': 'Mention',
}]
)
expected_activity = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{}
],
'actor': test_actor.url,
'id': 'https://{}/activities/note/{}/activity'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
'to': actor.url,
'type': 'Create',
'published': now.isoformat(),
'object': expected_note,
'cc': [],
}
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
deliver.assert_called_once_with(
expected_activity,
to=[actor.url],
on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
)

View File

@ -0,0 +1,42 @@
from funkwhale_api.federation import authentication
from funkwhale_api.federation import keys
from funkwhale_api.federation import signing
def test_authenticate(nodb_factories, mocker, api_request):
private, public = keys.get_key_pair()
actor_url = 'https://test.federation/actor'
mocker.patch(
'funkwhale_api.federation.actors.get_actor_data',
return_value={
'id': actor_url,
'outbox': 'https://test.com',
'inbox': 'https://test.com',
'publicKey': {
'publicKeyPem': public.decode('utf-8'),
'owner': actor_url,
'id': actor_url + '#main-key',
}
})
signed_request = nodb_factories['federation.SignedRequest'](
auth__key=private,
auth__key_id=actor_url + '#main-key',
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = api_request.get(
'/',
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
authenticator = authentication.SignatureAuthentication()
user, _ = authenticator.authenticate(django_request)
actor = django_request.actor
assert user.is_anonymous is True
assert actor.public_key == public.decode('utf-8')
assert actor.url == actor_url

View File

@ -0,0 +1,14 @@
from django.core.management import call_command
def test_generate_instance_key_pair(preferences, mocker):
mocker.patch(
'funkwhale_api.federation.keys.get_key_pair',
return_value=(b'private', b'public'))
assert preferences['federation__public_key'] == ''
assert preferences['federation__private_key'] == ''
call_command('generate_keys', interactive=False)
assert preferences['federation__private_key'] == 'private'
assert preferences['federation__public_key'] == 'public'

View File

@ -0,0 +1,25 @@
import pytest
from funkwhale_api.federation import keys
@pytest.mark.parametrize('raw, expected', [
('algorithm="test",keyId="https://test.com"', 'https://test.com'),
('keyId="https://test.com",algorithm="test"', 'https://test.com'),
])
def test_get_key_from_header(raw, expected):
r = keys.get_key_id_from_signature_header(raw)
assert r == expected
@pytest.mark.parametrize('raw', [
'algorithm="test",keyid="badCase"',
'algorithm="test",wrong="wrong"',
'keyId = "wrong"',
'keyId=\'wrong\'',
'keyId="notanurl"',
'keyId="wrong://test.com"',
])
def test_get_key_from_header_invalid(raw):
with pytest.raises(ValueError):
keys.get_key_id_from_signature_header(raw)

View File

@ -0,0 +1,146 @@
from django.urls import reverse
from funkwhale_api.federation import keys
from funkwhale_api.federation import models
from funkwhale_api.federation import serializers
def test_actor_serializer_from_ap(db):
payload = {
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
'name': 'Real User',
'summary': 'Hello world',
'url': 'https://test.federation/@user',
'manuallyApprovesFollowers': False,
'publicKey': {
'id': 'https://test.federation/user#main-key',
'owner': 'https://test.federation/user',
'publicKeyPem': 'yolo'
},
'endpoints': {
'sharedInbox': 'https://test.federation/inbox'
},
}
serializer = serializers.ActorSerializer(data=payload)
assert serializer.is_valid()
actor = serializer.build()
assert actor.url == payload['id']
assert actor.inbox_url == payload['inbox']
assert actor.outbox_url == payload['outbox']
assert actor.shared_inbox_url == payload['endpoints']['sharedInbox']
assert actor.followers_url == payload['followers']
assert actor.following_url == payload['following']
assert actor.public_key == payload['publicKey']['publicKeyPem']
assert actor.preferred_username == payload['preferredUsername']
assert actor.name == payload['name']
assert actor.domain == 'test.federation'
assert actor.summary == payload['summary']
assert actor.type == 'Person'
assert actor.manually_approves_followers == payload['manuallyApprovesFollowers']
def test_actor_serializer_only_mandatory_field_from_ap(db):
payload = {
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
}
serializer = serializers.ActorSerializer(data=payload)
assert serializer.is_valid()
actor = serializer.build()
assert actor.url == payload['id']
assert actor.inbox_url == payload['inbox']
assert actor.outbox_url == payload['outbox']
assert actor.followers_url == payload['followers']
assert actor.following_url == payload['following']
assert actor.preferred_username == payload['preferredUsername']
assert actor.domain == 'test.federation'
assert actor.type == 'Person'
assert actor.manually_approves_followers is None
def test_actor_serializer_to_ap():
expected = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{},
],
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
'name': 'Real User',
'summary': 'Hello world',
'manuallyApprovesFollowers': False,
'publicKey': {
'id': 'https://test.federation/user#main-key',
'owner': 'https://test.federation/user',
'publicKeyPem': 'yolo'
},
'endpoints': {
'sharedInbox': 'https://test.federation/inbox'
},
}
ac = models.Actor(
url=expected['id'],
inbox_url=expected['inbox'],
outbox_url=expected['outbox'],
shared_inbox_url=expected['endpoints']['sharedInbox'],
followers_url=expected['followers'],
following_url=expected['following'],
public_key=expected['publicKey']['publicKeyPem'],
preferred_username=expected['preferredUsername'],
name=expected['name'],
domain='test.federation',
summary=expected['summary'],
type='Person',
manually_approves_followers=False,
)
serializer = serializers.ActorSerializer(ac)
assert serializer.data == expected
def test_webfinger_serializer():
expected = {
'subject': 'acct:service@test.federation',
'links': [
{
'rel': 'self',
'href': 'https://test.federation/federation/instance/actor',
'type': 'application/activity+json',
}
],
'aliases': [
'https://test.federation/federation/instance/actor',
]
}
actor = models.Actor(
url=expected['links'][0]['href'],
preferred_username='service',
domain='test.federation',
)
serializer = serializers.ActorWebfingerSerializer(actor)
assert serializer.data == expected

View File

@ -0,0 +1,132 @@
import cryptography.exceptions
import io
import pytest
import requests_http_signature
from funkwhale_api.federation import signing
from funkwhale_api.federation import keys
def test_can_sign_and_verify_request(nodb_factories):
private, public = nodb_factories['federation.KeyPair']()
auth = nodb_factories['federation.SignatureAuth'](key=private)
request = nodb_factories['federation.SignedRequest'](
auth=auth
)
prepared_request = request.prepare()
assert 'date' in prepared_request.headers
assert 'signature' in prepared_request.headers
assert signing.verify(
prepared_request, public) is None
def test_can_sign_and_verify_request_digest(nodb_factories):
private, public = nodb_factories['federation.KeyPair']()
auth = nodb_factories['federation.SignatureAuth'](key=private)
request = nodb_factories['federation.SignedRequest'](
auth=auth,
method='post',
data=b'hello=world'
)
prepared_request = request.prepare()
assert 'date' in prepared_request.headers
assert 'digest' in prepared_request.headers
assert 'signature' in prepared_request.headers
assert signing.verify(prepared_request, public) is None
def test_verify_fails_with_wrong_key(nodb_factories):
wrong_private, wrong_public = nodb_factories['federation.KeyPair']()
request = nodb_factories['federation.SignedRequest']()
prepared_request = request.prepare()
with pytest.raises(cryptography.exceptions.InvalidSignature):
signing.verify(prepared_request, wrong_public)
def test_can_verify_django_request(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = fake_request.get(
'/',
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
assert signing.verify_django(django_request, public_key) is None
def test_can_verify_django_request_digest(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
data=b'hello=world',
auth__headers=[
'date',
'digest',
]
)
prepared = signed_request.prepare()
django_request = fake_request.post(
'/',
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_DIGEST': prepared.headers['digest'],
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
assert signing.verify_django(django_request, public_key) is None
def test_can_verify_django_request_digest_failure(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
data=b'hello=world',
auth__headers=[
'date',
'digest',
]
)
prepared = signed_request.prepare()
django_request = fake_request.post(
'/',
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_DIGEST': prepared.headers['digest'] + 'noop',
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
with pytest.raises(cryptography.exceptions.InvalidSignature):
signing.verify_django(django_request, public_key)
def test_can_verify_django_request_failure(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = fake_request.get(
'/',
**{
'HTTP_DATE': 'Wrong',
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
with pytest.raises(cryptography.exceptions.InvalidSignature):
signing.verify_django(django_request, public_key)

View File

@ -0,0 +1,48 @@
import pytest
from funkwhale_api.federation import utils
@pytest.mark.parametrize('url,path,expected', [
('http://test.com', '/hello', 'http://test.com/hello'),
('http://test.com/', 'hello', 'http://test.com/hello'),
('http://test.com/', '/hello', 'http://test.com/hello'),
('http://test.com', 'hello', 'http://test.com/hello'),
])
def test_full_url(settings, url, path, expected):
settings.FUNKWHALE_URL = url
assert utils.full_url(path) == expected
def test_extract_headers_from_meta():
wsgi_headers = {
'HTTP_HOST': 'nginx',
'HTTP_X_REAL_IP': '172.20.0.4',
'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4',
'HTTP_X_FORWARDED_PROTO': 'http',
'HTTP_X_FORWARDED_HOST': 'localhost:80',
'HTTP_X_FORWARDED_PORT': '80',
'HTTP_CONNECTION': 'close',
'CONTENT_LENGTH': '1155',
'CONTENT_TYPE': 'txt/application',
'HTTP_SIGNATURE': 'Hello',
'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT',
'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'}
cleaned_headers = utils.clean_wsgi_headers(wsgi_headers)
expected = {
'Host': 'nginx',
'X-Real-Ip': '172.20.0.4',
'X-Forwarded-For': '188.165.228.227, 172.20.0.4',
'X-Forwarded-Proto': 'http',
'X-Forwarded-Host': 'localhost:80',
'X-Forwarded-Port': '80',
'Connection': 'close',
'Content-Length': '1155',
'Content-Type': 'txt/application',
'Signature': 'Hello',
'Date': 'Sat, 31 Mar 2018 13:53:55 GMT',
'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'
}
assert cleaned_headers == expected

View File

@ -0,0 +1,64 @@
from django.urls import reverse
import pytest
from funkwhale_api.federation import actors
from funkwhale_api.federation import serializers
from funkwhale_api.federation import webfinger
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, settings, api_client):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse(
'federation:instance-actors-detail',
kwargs={'actor': system_actor})
response = api_client.get(url)
serializer = serializers.ActorSerializer(actor)
assert response.status_code == 200
assert response.data == serializer.data
@pytest.mark.parametrize('route,kwargs', [
('instance-actors-outbox', {'actor': 'library'}),
('instance-actors-inbox', {'actor': 'library'}),
('instance-actors-detail', {'actor': 'library'}),
('well-known-webfinger', {}),
])
def test_instance_endpoints_405_if_federation_disabled(
authenticated_actor, db, settings, api_client, route, kwargs):
settings.FEDERATION_ENABLED = False
url = reverse('federation:{}'.format(route), kwargs=kwargs)
response = api_client.get(url)
assert response.status_code == 405
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'
)
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_wellknown_webfinger_system(
system_actor, db, api_client, settings, mocker):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse('federation:well-known-webfinger')
response = api_client.get(
url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
serializer = serializers.ActorWebfingerSerializer(actor)
assert response.status_code == 200
assert response['Content-Type'] == 'application/jrd+json'
assert response.data == serializer.data

View File

@ -0,0 +1,42 @@
import pytest
from django import forms
from django.urls import reverse
from funkwhale_api.federation import webfinger
def test_webfinger_clean_resource():
t, r = webfinger.clean_resource('acct:service@test.federation')
assert t == 'acct'
assert r == 'service@test.federation'
@pytest.mark.parametrize('resource,message', [
('', 'Invalid resource string'),
('service@test.com', 'Missing webfinger resource type'),
('noop:service@test.com', 'Invalid webfinger resource type'),
])
def test_webfinger_clean_resource_errors(resource, message):
with pytest.raises(forms.ValidationError) as excinfo:
webfinger.clean_resource(resource)
assert message == str(excinfo)
def test_webfinger_clean_acct(settings):
username, hostname = webfinger.clean_acct('library@test.federation')
assert username == 'library'
assert hostname == 'test.federation'
@pytest.mark.parametrize('resource,message', [
('service', 'Invalid format'),
('service@test.com', 'Invalid hostname test.com'),
('noop@test.federation', 'Invalid account'),
])
def test_webfinger_clean_acct_errors(resource, message, settings):
with pytest.raises(forms.ValidationError) as excinfo:
webfinger.clean_resource(resource)
assert message == str(excinfo)

View File

View File

@ -23,7 +23,7 @@ def test_activity_listening_serializer(factories):
"id": listening.get_activity_url(),
"actor": actor,
"object": TrackActivitySerializer(listening.track).data,
"published": field.to_representation(listening.end_date),
"published": field.to_representation(listening.creation_date),
}
data = serializers.ListeningActivitySerializer(listening).data

View File

566
api/tests/music/conftest.py Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,502 +0,0 @@
artists = {'search': {}, 'get': {}}
artists['search']['adhesive_wombat'] = {
'artist-list': [
{
'type': 'Person',
'ext:score': '100',
'id': '62c3befb-6366-4585-b256-809472333801',
'disambiguation': 'George Shaw',
'gender': 'male',
'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'},
'sort-name': 'Wombat, Adhesive',
'life-span': {'ended': 'false'},
'name': 'Adhesive Wombat'
},
{
'country': 'SE',
'type': 'Group',
'ext:score': '42',
'id': '61b34e69-7573-4208-bc89-7061bca5a8fc',
'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'},
'sort-name': 'Adhesive',
'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'},
'name': 'Adhesive',
'begin-area': {
'sort-name': 'Katrineholm',
'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f',
'name': 'Katrineholm'
},
},
]
}
artists['get']['adhesive_wombat'] = {'artist': artists['search']['adhesive_wombat']['artist-list'][0]}
artists['get']['soad'] = {
'artist': {
'country': 'US',
'isni-list': ['0000000121055332'],
'type': 'Group',
'area': {
'iso-3166-1-code-list': ['US'],
'sort-name': 'United States',
'id': '489ce91b-6658-3307-9877-795b68554c98',
'name': 'United States'
},
'begin-area': {
'sort-name': 'Glendale',
'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373',
'name': 'Glendale'
},
'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
'life-span': {'begin': '1994'},
'sort-name': 'System of a Down',
'name': 'System of a Down'
}
}
albums = {'search': {}, 'get': {}, 'get_with_includes': {}}
albums['search']['hypnotize'] = {
'release-list': [
{
"artist-credit": [
{
"artist": {
"alias-list": [
{
"alias": "SoaD",
"sort-name": "SoaD",
"type": "Search hint"
},
{
"alias": "S.O.A.D.",
"sort-name": "S.O.A.D.",
"type": "Search hint"
},
{
"alias": "System Of Down",
"sort-name": "System Of Down",
"type": "Search hint"
}
],
"id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
"name": "System of a Down",
"sort-name": "System of a Down"
}
}
],
"artist-credit-phrase": "System of a Down",
"barcode": "",
"country": "US",
"date": "2005",
"ext:score": "100",
"id": "47ae093f-1607-49a3-be11-a15d335ccc94",
"label-info-list": [
{
"catalog-number": "8-2796-93871-2",
"label": {
"id": "f5be9cfe-e1af-405c-a074-caeaed6797c0",
"name": "American Recordings"
}
},
{
"catalog-number": "D162990",
"label": {
"id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f",
"name": "BMG Direct Marketing, Inc."
}
}
],
"medium-count": 1,
"medium-list": [
{
"disc-count": 1,
"disc-list": [],
"format": "CD",
"track-count": 12,
"track-list": []
}
],
"medium-track-count": 12,
"packaging": "Digipak",
"release-event-list": [
{
"area": {
"id": "489ce91b-6658-3307-9877-795b68554c98",
"iso-3166-1-code-list": [
"US"
],
"name": "United States",
"sort-name": "United States"
},
"date": "2005"
}
],
"release-group": {
"id": "72035143-d6ec-308b-8ee5-070b8703902a",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Hypnotize"
},
{
"artist-credit": [
{
"artist": {
"alias-list": [
{
"alias": "SoaD",
"sort-name": "SoaD",
"type": "Search hint"
},
{
"alias": "S.O.A.D.",
"sort-name": "S.O.A.D.",
"type": "Search hint"
},
{
"alias": "System Of Down",
"sort-name": "System Of Down",
"type": "Search hint"
}
],
"id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
"name": "System of a Down",
"sort-name": "System of a Down"
}
}
],
"artist-credit-phrase": "System of a Down",
"asin": "B000C6NRY8",
"barcode": "827969387115",
"country": "US",
"date": "2005-12-20",
"ext:score": "100",
"id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb",
"medium-count": 1,
"medium-list": [
{
"disc-count": 0,
"disc-list": [],
"format": "Vinyl",
"track-count": 12,
"track-list": []
}
],
"medium-track-count": 12,
"release-event-list": [
{
"area": {
"id": "489ce91b-6658-3307-9877-795b68554c98",
"iso-3166-1-code-list": [
"US"
],
"name": "United States",
"sort-name": "United States"
},
"date": "2005-12-20"
}
],
"release-group": {
"id": "72035143-d6ec-308b-8ee5-070b8703902a",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Hypnotize"
},
]
}
albums['get']['hypnotize'] = {'release': albums['search']['hypnotize']['release-list'][0]}
albums['get_with_includes']['hypnotize'] = {
'release': {
'artist-credit': [
{'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
'name': 'System of a Down',
'sort-name': 'System of a Down'}}],
'artist-credit-phrase': 'System of a Down',
'barcode': '',
'country': 'US',
'cover-art-archive': {'artwork': 'true',
'back': 'false',
'count': '1',
'front': 'true'},
'date': '2005',
'id': '47ae093f-1607-49a3-be11-a15d335ccc94',
'medium-count': 1,
'medium-list': [{'format': 'CD',
'position': '1',
'track-count': 12,
'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3',
'length': '186000',
'number': '1',
'position': '1',
'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68',
'length': '186000',
'title': 'Attack'},
'track_or_recording_length': '186000'},
{'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608',
'length': '239000',
'number': '2',
'position': '2',
'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a',
'length': '239000',
'title': 'Dreaming'},
'track_or_recording_length': '239000'},
{'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f',
'length': '147000',
'number': '3',
'position': '3',
'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344',
'length': '147000',
'title': 'Kill Rock n Roll'},
'track_or_recording_length': '147000'},
{'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25',
'length': '189000',
'number': '4',
'position': '4',
'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605',
'length': '189000',
'title': 'Hypnotize'},
'track_or_recording_length': '189000'},
{'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32',
'length': '178000',
'number': '5',
'position': '5',
'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2',
'length': '178000',
'title': 'Stealing Society'},
'track_or_recording_length': '178000'},
{'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2',
'length': '216000',
'number': '6',
'position': '6',
'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5',
'length': '216000',
'title': 'Tentative'},
'track_or_recording_length': '216000'},
{'id': '265718ba-787f-3193-947b-3b6fa69ffe96',
'length': '175000',
'number': '7',
'position': '7',
'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120',
'length': '175000',
'title': 'UFig'},
'title': 'U-Fig',
'track_or_recording_length': '175000'},
{'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a',
'length': '328000',
'number': '8',
'position': '8',
'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79',
'length': '328000',
'title': 'Holy Mountains'},
'track_or_recording_length': '328000'},
{'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df',
'length': '171000',
'number': '9',
'position': '9',
'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa',
'length': '171000',
'title': 'Vicinity of Obscenity'},
'track_or_recording_length': '171000'},
{'id': 'cdd45914-6741-353e-bbb5-d281048ff24f',
'length': '164000',
'number': '10',
'position': '10',
'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8',
'length': '164000',
'title': 'Shes Like Heroin'},
'track_or_recording_length': '164000'},
{'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d',
'length': '167000',
'number': '11',
'position': '11',
'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378',
'length': '167000',
'title': 'Lonely Day'},
'track_or_recording_length': '167000'},
{'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f',
'length': '220000',
'number': '12',
'position': '12',
'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88',
'length': '220000',
'title': 'Soldier Side'},
'track_or_recording_length': '220000'}]}],
'packaging': 'Digipak',
'quality': 'normal',
'release-event-count': 1,
'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98',
'iso-3166-1-code-list': ['US'],
'name': 'United States',
'sort-name': 'United States'},
'date': '2005'}],
'status': 'Official',
'text-representation': {'language': 'eng', 'script': 'Latn'},
'title': 'Hypnotize'}}
albums['get']['marsupial'] = {
'release': {
"artist-credit": [
{
"artist": {
"disambiguation": "George Shaw",
"id": "62c3befb-6366-4585-b256-809472333801",
"name": "Adhesive Wombat",
"sort-name": "Wombat, Adhesive"
}
}
],
"artist-credit-phrase": "Adhesive Wombat",
"country": "XW",
"cover-art-archive": {
"artwork": "true",
"back": "false",
"count": "1",
"front": "true"
},
"date": "2013-06-05",
"id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
"packaging": "None",
"quality": "normal",
"release-event-count": 1,
"release-event-list": [
{
"area": {
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-code-list": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]"
},
"date": "2013-06-05"
}
],
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Marsupial Madness"
}
}
tracks = {'search': {}, 'get': {}}
tracks['search']['8bitadventures'] = {
'recording-list': [
{
"artist-credit": [
{
"artist": {
"disambiguation": "George Shaw",
"id": "62c3befb-6366-4585-b256-809472333801",
"name": "Adhesive Wombat",
"sort-name": "Wombat, Adhesive"
}
}
],
"artist-credit-phrase": "Adhesive Wombat",
"ext:score": "100",
"id": "9968a9d6-8d92-4051-8f76-674e157b6eed",
"length": "271000",
"release-list": [
{
"country": "XW",
"date": "2013-06-05",
"id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
"medium-list": [
{
"format": "Digital Media",
"position": "1",
"track-count": 11,
"track-list": [
{
"id": "64d43604-c1ee-4f45-a02c-030672d2fe27",
"length": "271000",
"number": "1",
"title": "8-Bit Adventure",
"track_or_recording_length": "271000"
}
]
}
],
"medium-track-count": 11,
"release-event-list": [
{
"area": {
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-code-list": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]"
},
"date": "2013-06-05"
}
],
"release-group": {
"id": "447b4979-2178-405c-bfe6-46bf0b09e6c7",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"title": "Marsupial Madness"
}
],
"title": "8-Bit Adventure",
"tag-list": [
{
"count": "2",
"name": "techno"
},
{
"count": "2",
"name": "good-music"
},
],
},
]
}
tracks['get']['8bitadventures'] = {'recording': tracks['search']['8bitadventures']['recording-list'][0]}
tracks['get']['chop_suey'] = {
'recording': {
'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
'length': '210240',
'title': 'Chop Suey!',
'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'type': 'performance',
'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'language': 'eng',
'title': 'Chop Suey!'}}]}}
works = {'search': {}, 'get': {}}
works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'language': 'eng',
'recording-relation-list': [{'direction': 'backward',
'recording': {'disambiguation': 'edit',
'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
'length': '170893',
'title': 'Chop Suey!'},
'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
'type': 'performance',
'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
],
'title': 'Chop Suey!',
'type': 'Song',
'url-relation-list': [{'direction': 'backward',
'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
'type': 'lyrics',
'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}

File diff suppressed because one or more lines are too long

View File

@ -8,34 +8,40 @@ from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
from . import data as api_data
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
def test_can_submit_youtube_url_for_track_import(
settings, artists, albums, tracks, mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['8bitadventures'])
return_value=tracks['get']['8bitadventures'])
mocker.patch(
'funkwhale_api.music.models.TrackFile.download_file',
return_value=None)
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
video_id = 'tPEE9ZwTmy0'
url = reverse('api:v1:submit-single')
video_url = 'https://www.youtube.com/watch?v={0}'.format(video_id)
response = superuser_client.post(
url,
{'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
{'import_url': video_url,
'mbid': mbid})
track = models.Track.objects.get(mbid=mbid)
assert track.artist.name == 'Adhesive Wombat'
assert track.album.title == 'Marsupial Madness'
assert response.status_code == 201
batch = superuser_client.user.imports.latest('id')
job = batch.jobs.latest('id')
assert job.status == 'pending'
assert str(job.mbid) == mbid
assert job.source == video_url
def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
@ -58,17 +64,18 @@ def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
def test_can_import_whole_album(mocker, superuser_client):
def test_can_import_whole_album(
artists, albums, mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad'])
return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get_with_includes']['hypnotize'])
return_value=albums['get_with_includes']['hypnotize'])
payload = {
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'tracks': [
@ -97,7 +104,7 @@ def test_can_import_whole_album(mocker, superuser_client):
album = models.Album.objects.latest('id')
assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
assert int(medium_data['track-count']) == album.tracks.all().count()
for track in medium_data['track-list']:
@ -113,17 +120,18 @@ def test_can_import_whole_album(mocker, superuser_client):
assert job.source == row['source']
def test_can_import_whole_artist(mocker, superuser_client):
def test_can_import_whole_artist(
artists, albums, mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad'])
return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get_with_includes']['hypnotize'])
return_value=albums['get_with_includes']['hypnotize'])
payload = {
'artistId': 'mbid',
'albums': [
@ -157,7 +165,7 @@ def test_can_import_whole_artist(mocker, superuser_client):
album = models.Album.objects.latest('id')
assert str(album.mbid) == '47ae093f-1607-49a3-be11-a15d335ccc94'
medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
medium_data = albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
assert int(medium_data['track-count']) == album.tracks.all().count()
for track in medium_data['track-list']:
@ -173,55 +181,57 @@ def test_can_import_whole_artist(mocker, superuser_client):
assert job.source == row['source']
def test_user_can_query_api_for_his_own_batches(client, factories):
user1 = factories['users.SuperUser']()
user2 = factories['users.SuperUser']()
job = factories['music.ImportJob'](batch__submitted_by=user1)
def test_user_can_query_api_for_his_own_batches(
superuser_api_client, factories):
factories['music.ImportJob']()
job = factories['music.ImportJob'](
batch__submitted_by=superuser_api_client.user)
url = reverse('api:v1:import-batches-list')
client.login(username=user2.username, password='test')
response2 = client.get(url)
results = json.loads(response2.content.decode('utf-8'))
assert results['count'] == 0
client.logout()
client.login(username=user1.username, password='test')
response1 = client.get(url)
results = json.loads(response1.content.decode('utf-8'))
response = superuser_api_client.get(url)
results = response.data
assert results['count'] == 1
assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
def test_user_can_create_an_empty_batch(client, factories):
user = factories['users.SuperUser']()
def test_user_cannnot_access_other_batches(
superuser_api_client, factories):
factories['music.ImportJob']()
job = factories['music.ImportJob']()
url = reverse('api:v1:import-batches-list')
client.login(username=user.username, password='test')
response = client.post(url)
response = superuser_api_client.get(url)
results = response.data
assert results['count'] == 0
def test_user_can_create_an_empty_batch(superuser_api_client, factories):
url = reverse('api:v1:import-batches-list')
response = superuser_api_client.post(url)
assert response.status_code == 201
batch = user.imports.latest('id')
batch = superuser_api_client.user.imports.latest('id')
assert batch.submitted_by == user
assert batch.submitted_by == superuser_api_client.user
assert batch.source == 'api'
def test_user_can_create_import_job_with_file(client, factories, mocker):
def test_user_can_create_import_job_with_file(
superuser_api_client, factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
m = mocker.patch('funkwhale_api.common.utils.on_commit')
user = factories['users.SuperUser']()
batch = factories['music.ImportBatch'](submitted_by=user)
batch = factories['music.ImportBatch'](
submitted_by=superuser_api_client.user)
url = reverse('api:v1:import-jobs-list')
client.login(username=user.username, password='test')
with open(path, 'rb') as f:
content = f.read()
f.seek(0)
response = client.post(url, {
response = superuser_api_client.post(url, {
'batch': batch.pk,
'audio_file': f,
'source': 'file://'
}, format='multipart')
})
assert response.status_code == 201
@ -237,16 +247,16 @@ def test_user_can_create_import_job_with_file(client, factories, mocker):
import_job_id=job.pk)
def test_can_search_artist(factories, client):
def test_can_search_artist(factories, logged_in_client):
artist1 = factories['music.Artist']()
artist2 = factories['music.Artist']()
expected = [serializers.ArtistSerializerNested(artist1).data]
url = reverse('api:v1:artists-search')
response = client.get(url, {'query': artist1.name})
assert json.loads(response.content.decode('utf-8')) == expected
response = logged_in_client.get(url, {'query': artist1.name})
assert response.data == expected
def test_can_search_artist_by_name_start(factories, client):
def test_can_search_artist_by_name_start(factories, logged_in_client):
artist1 = factories['music.Artist'](name='alpha')
artist2 = factories['music.Artist'](name='beta')
expected = {
@ -256,20 +266,20 @@ def test_can_search_artist_by_name_start(factories, client):
'results': [serializers.ArtistSerializerNested(artist1).data]
}
url = reverse('api:v1:artists-list')
response = client.get(url, {'name__startswith': 'a'})
response = logged_in_client.get(url, {'name__startswith': 'a'})
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_search_tracks(factories, client):
def test_can_search_tracks(factories, logged_in_client):
track1 = factories['music.Track'](title="test track 1")
track2 = factories['music.Track']()
query = 'test track 1'
expected = [serializers.TrackSerializerNested(track1).data]
url = reverse('api:v1:tracks-search')
response = client.get(url, {'query': query})
response = logged_in_client.get(url, {'query': query})
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
@pytest.mark.parametrize('route,method', [
@ -278,24 +288,31 @@ def test_can_search_tracks(factories, client):
('api:v1:artists-list', 'get'),
('api:v1:albums-list', 'get'),
])
def test_can_restrict_api_views_to_authenticated_users(db, route, method, settings, client):
def test_can_restrict_api_views_to_authenticated_users(
db, route, method, settings, client):
url = reverse(route)
settings.API_AUTHENTICATION_REQUIRED = True
response = getattr(client, method)(url)
assert response.status_code == 401
def test_track_file_url_is_restricted_to_authenticated_users(client, factories, settings):
def test_track_file_url_is_restricted_to_authenticated_users(
api_client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = True
f = factories['music.TrackFile']()
assert f.audio_file is not None
url = f.path
response = client.get(url)
response = api_client.get(url)
assert response.status_code == 401
user = factories['users.SuperUser']()
client.login(username=user.username, password='test')
response = client.get(url)
def test_track_file_url_is_accessible_to_authenticated_users(
logged_in_api_client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = True
f = factories['music.TrackFile']()
assert f.audio_file is not None
url = f.path
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '/_protected{}'.format(f.audio_file.url)

View File

@ -2,23 +2,21 @@ import json
from django.urls import reverse
from . import data as api_data
def test_create_import_can_bind_to_request(
mocker, factories, superuser_api_client):
artists, albums, mocker, factories, superuser_api_client):
request = factories['requests.ImportRequest']()
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad'])
return_value=artists['get']['soad'])
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=b'')
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get_with_includes']['hypnotize'])
return_value=albums['get_with_includes']['hypnotize'])
payload = {
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'importRequest': request.pk,

View File

@ -7,15 +7,12 @@ from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
from funkwhale_api.music import lyrics as lyrics_utils
from .mocking import lyricswiki
from . import data as api_data
def test_works_import_lyrics_if_any(mocker, factories):
def test_works_import_lyrics_if_any(
lyricswiki_content, mocker, factories):
mocker.patch(
'funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
return_value=lyricswiki_content)
lyrics = factories['music.Lyrics'](
url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
@ -48,16 +45,22 @@ Is it me you're looking for?"""
assert expected == l.content_rendered
def test_works_import_lyrics_if_any(mocker, factories, logged_in_client):
def test_works_import_lyrics_if_any(
lyricswiki_content,
works,
tracks,
mocker,
factories,
logged_in_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
return_value=works['get']['chop_suey'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['chop_suey'])
return_value=tracks['get']['chop_suey'])
mocker.patch(
'funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
return_value=lyricswiki_content)
track = factories['music.Track'](
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')

View File

@ -2,14 +2,11 @@ import pytest
from funkwhale_api.music import models
import datetime
from . import data as api_data
from .cover import binary_data
def test_can_create_artist_from_api(mocker, db):
def test_can_create_artist_from_api(artists, mocker, db):
mocker.patch(
'musicbrainzngs.search_artists',
return_value=api_data.artists['search']['adhesive_wombat'])
return_value=artists['search']['adhesive_wombat'])
artist = models.Artist.create_from_api(query="Adhesive wombat")
data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
@ -19,13 +16,13 @@ def test_can_create_artist_from_api(mocker, db):
assert artist.name, 'Adhesive Wombat'
def test_can_create_album_from_api(mocker, db):
def test_can_create_album_from_api(artists, albums, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.search',
return_value=api_data.albums['search']['hypnotize'])
return_value=albums['search']['hypnotize'])
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad'])
return_value=artists['get']['soad'])
album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
@ -38,16 +35,16 @@ def test_can_create_album_from_api(mocker, db):
assert album.artist.mbid, data['artist-credit'][0]['artist']['id']
def test_can_create_track_from_api(mocker, db):
def test_can_create_track_from_api(artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
return_value=tracks['search']['8bitadventures'])
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
assert int(data['ext:score']) == 100
@ -60,16 +57,17 @@ def test_can_create_track_from_api(mocker, db):
assert track.album.title == 'Marsupial Madness'
def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
def test_can_create_track_from_api_with_corresponding_tags(
artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['8bitadventures'])
return_value=tracks['get']['8bitadventures'])
track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
expected_tags = ['techno', 'good-music']
track_tags = [tag.slug for tag in track.tags.all()]
@ -77,16 +75,17 @@ def test_can_create_track_from_api_with_corresponding_tags(mocker, db):
assert tag in track_tags
def test_can_get_or_create_track_from_api(mocker, db):
def test_can_get_or_create_track_from_api(
artists, albums, tracks, mocker, db):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
return_value=tracks['search']['8bitadventures'])
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
assert int(data['ext:score']) == 100
@ -126,13 +125,13 @@ def test_artist_tags_deduced_from_album_tags(factories, django_assert_num_querie
assert tag in artist.tags
def test_can_download_image_file_for_album(mocker, factories):
def test_can_download_image_file_for_album(binary_cover, mocker, factories):
mocker.patch(
'funkwhale_api.musicbrainz.api.images.get_front',
return_value=binary_data)
return_value=binary_cover)
# client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album = factories['music.Album'](mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album.get_image()
album.save()
assert album.cover.file.read() == binary_data
assert album.cover.file.read() == binary_cover

View File

@ -4,8 +4,6 @@ import pytest
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.music import tasks
from . import data as api_data
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@ -50,7 +48,7 @@ def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
def test_import_job_can_run_with_file_and_acoustid(
preferences, factories, mocker):
artists, albums, tracks, preferences, factories, mocker):
preferences['providers_acoustid__api_key'] = 'test'
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@ -66,13 +64,13 @@ def test_import_job_can_run_with_file_and_acoustid(
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
return_value=tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)
@ -129,7 +127,8 @@ def test__do_import_skipping_accoustid_if_no_key(
m.assert_called_once_with(p)
def test_import_job_can_be_skipped(factories, mocker, preferences):
def test_import_job_can_be_skipped(
artists, albums, tracks, factories, mocker, preferences):
preferences['providers_acoustid__api_key'] = 'test'
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
@ -146,13 +145,13 @@ def test_import_job_can_be_skipped(factories, mocker, preferences):
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
return_value=artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
return_value=albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
return_value=tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)

View File

@ -5,13 +5,11 @@ from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from . import data as api_data
def test_can_import_work(factories, mocker):
def test_can_import_work(factories, mocker, works):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
return_value=works['get']['chop_suey'])
recording = factories['music.Track'](
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
@ -28,13 +26,13 @@ def test_can_import_work(factories, mocker):
assert recording.work == work
def test_can_get_work_from_recording(factories, mocker):
def test_can_get_work_from_recording(factories, mocker, works, tracks):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
return_value=works['get']['chop_suey'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['chop_suey'])
return_value=tracks['get']['chop_suey'])
recording = factories['music.Track'](
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
@ -53,10 +51,10 @@ def test_can_get_work_from_recording(factories, mocker):
assert recording.work == work
def test_works_import_lyrics_if_any(db, mocker):
def test_works_import_lyrics_if_any(db, mocker, works):
mocker.patch(
'funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
return_value=works['get']['chop_suey'])
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
work = models.Work.create_from_api(id=mbid)

View File

@ -1,5 +1,7 @@
artists = {'search': {}, 'get': {}}
artists['search']['lost fingers'] = {
import pytest
_artists = {'search': {}, 'get': {}}
_artists['search']['lost fingers'] = {
'artist-count': 696,
'artist-list': [
{
@ -21,7 +23,7 @@ artists['search']['lost fingers'] = {
},
]
}
artists['get']['lost fingers'] = {
_artists['get']['lost fingers'] = {
"artist": {
"life-span": {
"begin": "2008"
@ -102,8 +104,8 @@ artists['get']['lost fingers'] = {
}
release_groups = {'browse': {}}
release_groups['browse']["lost fingers"] = {
_release_groups = {'browse': {}}
_release_groups['browse']["lost fingers"] = {
"release-group-list": [
{
"first-release-date": "2010",
@ -165,8 +167,8 @@ release_groups['browse']["lost fingers"] = {
"release-group-count": 8
}
recordings = {'search': {}, 'get': {}}
recordings['search']['brontide matador'] = {
_recordings = {'search': {}, 'get': {}}
_recordings['search']['brontide matador'] = {
"recording-count": 1044,
"recording-list": [
{
@ -217,8 +219,8 @@ recordings['search']['brontide matador'] = {
]
}
releases = {'search': {}, 'get': {}, 'browse': {}}
releases['search']['brontide matador'] = {
_releases = {'search': {}, 'get': {}, 'browse': {}}
_releases['search']['brontide matador'] = {
"release-count": 116, "release-list": [
{
"ext:score": "100",
@ -283,7 +285,7 @@ releases['search']['brontide matador'] = {
]
}
releases['browse']['Lost in the 80s'] = {
_releases['browse']['Lost in the 80s'] = {
"release-count": 3,
"release-list": [
{
@ -476,3 +478,23 @@ releases['browse']['Lost in the 80s'] = {
},
]
}
@pytest.fixture()
def releases():
return _releases
@pytest.fixture()
def release_groups():
return _release_groups
@pytest.fixture()
def artists():
return _artists
@pytest.fixture()
def recordings():
return _recordings

View File

@ -2,64 +2,65 @@ import json
from django.urls import reverse
from funkwhale_api.musicbrainz import api
from . import data as api_data
def test_can_search_recording_in_musicbrainz_api(db, mocker, client):
def test_can_search_recording_in_musicbrainz_api(
recordings, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.recordings['search']['brontide matador'])
return_value=recordings['search']['brontide matador'])
query = 'brontide matador'
url = reverse('api:v1:providers:musicbrainz:search-recordings')
expected = api_data.recordings['search']['brontide matador']
response = client.get(url, data={'query': query})
expected = recordings['search']['brontide matador']
response = logged_in_api_client.get(url, data={'query': query})
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_search_release_in_musicbrainz_api(db, mocker, client):
def test_can_search_release_in_musicbrainz_api(releases, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.search',
return_value=api_data.releases['search']['brontide matador'])
return_value=releases['search']['brontide matador'])
query = 'brontide matador'
url = reverse('api:v1:providers:musicbrainz:search-releases')
expected = api_data.releases['search']['brontide matador']
response = client.get(url, data={'query': query})
expected = releases['search']['brontide matador']
response = logged_in_api_client.get(url, data={'query': query})
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_search_artists_in_musicbrainz_api(db, mocker, client):
def test_can_search_artists_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.search',
return_value=api_data.artists['search']['lost fingers'])
return_value=artists['search']['lost fingers'])
query = 'lost fingers'
url = reverse('api:v1:providers:musicbrainz:search-artists')
expected = api_data.artists['search']['lost fingers']
response = client.get(url, data={'query': query})
expected = artists['search']['lost fingers']
response = logged_in_api_client.get(url, data={'query': query})
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_get_artist_in_musicbrainz_api(db, mocker, client):
def test_can_get_artist_in_musicbrainz_api(artists, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['lost fingers'])
return_value=artists['get']['lost fingers'])
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse('api:v1:providers:musicbrainz:artist-detail', kwargs={
'uuid': uuid,
})
response = client.get(url)
expected = api_data.artists['get']['lost fingers']
response = logged_in_api_client.get(url)
expected = artists['get']['lost fingers']
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
def test_can_broswe_release_group_using_musicbrainz_api(
release_groups, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.release_groups.browse',
return_value=api_data.release_groups['browse']['lost fingers'])
return_value=release_groups['browse']['lost fingers'])
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse(
'api:v1:providers:musicbrainz:release-group-browse',
@ -67,16 +68,17 @@ def test_can_broswe_release_group_using_musicbrainz_api(db, mocker, client):
'artist_uuid': uuid,
}
)
response = client.get(url)
expected = api_data.release_groups['browse']['lost fingers']
response = logged_in_api_client.get(url)
expected = release_groups['browse']['lost fingers']
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data
def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
def test_can_broswe_releases_using_musicbrainz_api(
releases, db, mocker, logged_in_api_client):
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.browse',
return_value=api_data.releases['browse']['Lost in the 80s'])
return_value=releases['browse']['Lost in the 80s'])
uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
url = reverse(
'api:v1:providers:musicbrainz:release-browse',
@ -84,7 +86,7 @@ def test_can_broswe_releases_using_musicbrainz_api(db, mocker, client):
'release_group_uuid': uuid,
}
)
response = client.get(url)
expected = api_data.releases['browse']['Lost in the 80s']
response = logged_in_api_client.get(url)
expected = releases['browse']['Lost in the 80s']
assert expected == json.loads(response.content.decode('utf-8'))
assert expected == response.data

View File

View File

@ -106,7 +106,9 @@ def test_deleting_plt_updates_indexes(
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
def test_playlist_privacy_respected_in_list_anon(
settings, level, factories, api_client):
settings.API_AUTHENTICATION_REQUIRED = False
factories['playlists.Playlist'](privacy_level=level)
url = reverse('api:v1:playlists-list')
response = api_client.get(url)
@ -115,26 +117,28 @@ def test_playlist_privacy_respected_in_list_anon(level, factories, api_client):
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
def test_only_owner_can_edit_playlist(method, factories, api_client):
def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
playlist = factories['playlists.Playlist']()
url = reverse('api:v1:playlists-detail', kwargs={'pk': playlist.pk})
response = api_client.get(url)
response = getattr(logged_in_api_client, method.lower())(url)
assert response.status_code == 404
@pytest.mark.parametrize('method', ['PUT', 'PATCH', 'DELETE'])
def test_only_owner_can_edit_playlist_track(method, factories, api_client):
def test_only_owner_can_edit_playlist_track(
method, factories, logged_in_api_client):
plt = factories['playlists.PlaylistTrack']()
url = reverse('api:v1:playlist-tracks-detail', kwargs={'pk': plt.pk})
response = api_client.get(url)
response = getattr(logged_in_api_client, method.lower())(url)
assert response.status_code == 404
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_track_privacy_respected_in_list_anon(
level, factories, api_client):
level, factories, api_client, settings):
settings.API_AUTHENTICATION_REQUIRED = False
factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
url = reverse('api:v1:playlist-tracks-list')
response = api_client.get(url)

View File

View File

@ -151,14 +151,18 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
assert session.user == logged_in_client.user
def test_can_start_radio_for_anonymous_user(client, db):
def test_can_start_radio_for_anonymous_user(api_client, db, settings):
settings.API_AUTHENTICATION_REQUIRED = False
url = reverse('api:v1:radios:sessions-list')
response = client.post(url, {'radio_type': 'random'})
response = api_client.post(url, {'radio_type': 'random'})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type == 'random'
assert session.user is None
assert session.session_key == client.session.session_key
assert session.session_key == api_client.session.session_key
def test_can_get_track_for_session_from_api(factories, logged_in_client):
@ -228,13 +232,18 @@ def test_can_start_tag_radio(factories):
assert radio.pick() in good_tracks
def test_can_start_artist_radio_from_api(client, factories):
def test_can_start_artist_radio_from_api(api_client, settings, factories):
settings.API_AUTHENTICATION_REQUIRED = False
artist = factories['music.Artist']()
url = reverse('api:v1:radios:sessions-list')
response = client.post(
response = api_client.post(
url, {'radio_type': 'artist', 'related_object_id': artist.id})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type, 'artist'
assert session.related_object, artist

View File

@ -98,3 +98,27 @@ def test_import_files_skip_acoustid(factories, mocker):
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False)
def test_import_files_works_with_utf8_file_name(factories, mocker):
m = mocker.patch('funkwhale_api.common.utils.on_commit')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
call_command(
'import_files',
path,
username='me',
async=True,
no_acoustid=True,
interactive=False)
batch = user.imports.latest('id')
job = batch.jobs.first()
m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False)
def test_storage_rename_utf_8_files(factories):
tf = factories['music.TrackFile'](audio_file__filename='été.ogg')
assert tf.audio_file.name.endswith('ete.ogg')

View File

@ -17,13 +17,15 @@ def test_can_get_search_results_from_youtube(mocker):
assert results[0]['full_url'] == 'https://www.youtube.com/watch?v=0HxZn6CzOIo'
def test_can_get_search_results_from_funkwhale(mocker, client, db):
def test_can_get_search_results_from_funkwhale(
settings, mocker, api_client, db):
settings.API_AUTHENTICATION_REQUIRED = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
query = '8 bit adventure'
url = reverse('api:v1:providers:youtube:search')
response = client.get(url, {'query': query})
response = api_client.get(url, {'query': query})
# we should cast the youtube result to something more generic
expected = {
"id": "0HxZn6CzOIo",
@ -37,7 +39,7 @@ def test_can_get_search_results_from_funkwhale(mocker, client, db):
"cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg"
}
assert json.loads(response.content.decode('utf-8'))[0] == expected
assert response.data[0] == expected
def test_can_send_multiple_queries_at_once(mocker):
@ -67,7 +69,9 @@ def test_can_send_multiple_queries_at_once(mocker):
assert results['2'][0]['full_url'] == 'https://www.youtube.com/watch?v=BorYwGi2SJc'
def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
def test_can_send_multiple_queries_at_once_from_funwkhale(
settings, mocker, db, api_client):
settings.API_AUTHENTICATION_REQUIRED = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
@ -89,7 +93,6 @@ def test_can_send_multiple_queries_at_once_from_funwkhale(mocker, db, client):
}
url = reverse('api:v1:providers:youtube:searchs')
response = client.post(
url, json.dumps(queries), content_type='application/json')
response = api_client.post(url, queries, format='json')
assert expected == json.loads(response.content.decode('utf-8'))['1'][0]
assert expected == response.data['1'][0]

View File

@ -23,6 +23,23 @@ def test_can_create_user_via_api(preferences, client, db):
assert u.username == 'test1'
def test_can_restrict_usernames(settings, preferences, db, client):
url = reverse('rest_register')
preferences['users__registration_enabled'] = True
settings.USERNAME_BLACKLIST = ['funkwhale']
data = {
'username': 'funkwhale',
'email': 'contact@funkwhale.io',
'password1': 'testtest',
'password2': 'testtest',
}
response = client.post(url, data)
assert response.status_code == 400
assert 'username' in response.data
def test_can_disable_registration_view(preferences, client, db):
url = reverse('rest_register')
data = {

View File

@ -4,12 +4,13 @@
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
# variables:
# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
# - STATIC_ROOT
# - MEDIA_ROOT
#
# You **don't** need to update those variables on pure docker setups.
# Docker only
# -----------

View File

@ -3,8 +3,8 @@ proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
# websocket support

Some files were not shown because too many files have changed in this diff Show More