mirror of
https://github.com/spl0k/supysonic.git
synced 2024-12-22 17:06:17 +00:00
Rewritten and improved existing tests
This commit is contained in:
parent
d8c3b9fa88
commit
d19886fafa
1
setup.py
1
setup.py
@ -35,6 +35,7 @@ setup(
|
|||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
test_suite="tests.suite",
|
test_suite="tests.suite",
|
||||||
|
tests_require = [ 'lxml' ],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 3 - Alpha',
|
'Development Status :: 3 - Alpha',
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
|
@ -13,7 +13,6 @@ import unittest
|
|||||||
|
|
||||||
import base, managers, api
|
import base, managers, api
|
||||||
|
|
||||||
from .test_api import ApiTestCase
|
|
||||||
from .test_frontend import FrontendTestCase
|
from .test_frontend import FrontendTestCase
|
||||||
|
|
||||||
def suite():
|
def suite():
|
||||||
@ -23,7 +22,6 @@ def suite():
|
|||||||
suite.addTest(api.suite())
|
suite.addTest(api.suite())
|
||||||
|
|
||||||
suite.addTest(managers.suite())
|
suite.addTest(managers.suite())
|
||||||
suite.addTest(unittest.makeSuite(ApiTestCase))
|
|
||||||
suite.addTest(unittest.makeSuite(FrontendTestCase))
|
suite.addTest(unittest.makeSuite(FrontendTestCase))
|
||||||
|
|
||||||
return suite
|
return suite
|
||||||
|
@ -12,12 +12,16 @@ import unittest
|
|||||||
|
|
||||||
from .test_response_helper import suite as rh_suite
|
from .test_response_helper import suite as rh_suite
|
||||||
from .test_api_setup import ApiSetupTestCase
|
from .test_api_setup import ApiSetupTestCase
|
||||||
|
from .test_system import SystemTestCase
|
||||||
|
from .test_user import UserTestCase
|
||||||
|
|
||||||
def suite():
|
def suite():
|
||||||
suite = unittest.TestSuite()
|
suite = unittest.TestSuite()
|
||||||
|
|
||||||
suite.addTest(rh_suite())
|
suite.addTest(rh_suite())
|
||||||
suite.addTest(unittest.makeSuite(ApiSetupTestCase))
|
suite.addTest(unittest.makeSuite(ApiSetupTestCase))
|
||||||
|
suite.addTest(unittest.makeSuite(SystemTestCase))
|
||||||
|
suite.addTest(unittest.makeSuite(UserTestCase))
|
||||||
|
|
||||||
return suite
|
return suite
|
||||||
|
|
||||||
|
104
tests/api/apitestbase.py
Normal file
104
tests/api/apitestbase.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# vim:fenc=utf-8
|
||||||
|
#
|
||||||
|
# This file is part of Supysonic.
|
||||||
|
# Supysonic is a Python implementation of the Subsonic server API.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Alban 'spl0k' Féron
|
||||||
|
#
|
||||||
|
# Distributed under terms of the GNU AGPLv3 license.
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from supysonic.managers.user import UserManager
|
||||||
|
|
||||||
|
from .appmock import AppMock
|
||||||
|
|
||||||
|
path_replace_regexp = re.compile(r'/(\w+)')
|
||||||
|
|
||||||
|
NS = '{http://subsonic.org/restapi}'
|
||||||
|
|
||||||
|
class ApiTestBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
app_mock = AppMock()
|
||||||
|
self.app = app_mock.app
|
||||||
|
self.store = app_mock.store
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
sys.modules['supysonic.web'] = app_mock
|
||||||
|
import supysonic.api
|
||||||
|
|
||||||
|
UserManager.add(self.store, 'alice', 'Alic3', 'test@example.com', True)
|
||||||
|
UserManager.add(self.store, 'bob', 'B0b', 'bob@example.com', False)
|
||||||
|
|
||||||
|
xsd = etree.parse('tests/assets/subsonic-rest-api-1.8.0.xsd')
|
||||||
|
self.schema = etree.XMLSchema(xsd)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
to_unload = [ m for m in sys.modules if m.startswith('supysonic') ]
|
||||||
|
for m in to_unload:
|
||||||
|
del sys.modules[m]
|
||||||
|
|
||||||
|
def _find(self, xml, path):
|
||||||
|
"""
|
||||||
|
Helper method that insert the namespace in XPath 'path'
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = path_replace_regexp.sub(r'/{}\1'.format(NS), path)
|
||||||
|
return xml.find(path)
|
||||||
|
|
||||||
|
def _make_request(self, endpoint, args = {}, tag = None, error = None, skip_post = False):
|
||||||
|
"""
|
||||||
|
Makes both a GET and POST requests against the API, assert both get the same response.
|
||||||
|
If the user isn't provided with the 'u' and 'p' in 'args', the default 'alice' is used.
|
||||||
|
Validate the response against the Subsonic API XSD and assert it matches the expected tag or error.
|
||||||
|
|
||||||
|
:param endpoint: request endpoint, with the '/rest/'prefix nor the '.view' suffix
|
||||||
|
:param args: dict of parameters. 'u', 'p', 'c' and 'v' are automatically added
|
||||||
|
:param tag: topmost expected element, right beneath 'subsonic-response'
|
||||||
|
:param error: if the given 'args' should produce an error at 'endpoint', this is the expected error code
|
||||||
|
:param skip_post: don't do the POST request
|
||||||
|
|
||||||
|
:return: a 2-tuple (resp, child) if no error, where 'resp' is the full response object, 'child' a
|
||||||
|
'lxml.etree.Element' mathching 'tag' (if any). If there's an error (when expected), only returns
|
||||||
|
the response object
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
raise TypeError("'args', expecting a dict, got " + type(args).__name__)
|
||||||
|
if tag and not isinstance(tag, basestring):
|
||||||
|
raise TypeError("'tag', expecting a str, got " + type(tag).__name__)
|
||||||
|
|
||||||
|
args.update({ 'c': 'tests', 'v': '1.8.0' })
|
||||||
|
if 'u' not in args:
|
||||||
|
args.update({ 'u': 'alice', 'p': 'Alic3' })
|
||||||
|
|
||||||
|
uri = '/rest/{}.view'.format(endpoint)
|
||||||
|
rg = self.client.get(uri, query_string = args)
|
||||||
|
if not skip_post:
|
||||||
|
rp = self.client.post(uri, data = args)
|
||||||
|
self.assertEqual(rg.data, rp.data)
|
||||||
|
|
||||||
|
xml = etree.fromstring(rg.data)
|
||||||
|
self.schema.assert_(xml)
|
||||||
|
|
||||||
|
if 'status="ok"' in rg.data:
|
||||||
|
self.assertIsNone(error)
|
||||||
|
if tag:
|
||||||
|
self.assertEqual(xml[0].tag, NS + tag)
|
||||||
|
return rg, xml[0]
|
||||||
|
else:
|
||||||
|
self.assertEqual(len(xml), 0)
|
||||||
|
return rg, None
|
||||||
|
else:
|
||||||
|
self.assertIsNone(tag)
|
||||||
|
self.assertEqual(xml[0].tag, NS + 'error')
|
||||||
|
self.assertEqual(xml[0].get('code'), str(error))
|
||||||
|
return rg
|
||||||
|
|
25
tests/api/test_system.py
Normal file
25
tests/api/test_system.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# vim:fenc=utf-8
|
||||||
|
#
|
||||||
|
# This file is part of Supysonic.
|
||||||
|
# Supysonic is a Python implementation of the Subsonic server API.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Alban 'spl0k' Féron
|
||||||
|
# 2017 Óscar García Amor
|
||||||
|
#
|
||||||
|
# Distributed under terms of the GNU AGPLv3 license.
|
||||||
|
|
||||||
|
from .apitestbase import ApiTestBase
|
||||||
|
|
||||||
|
class SystemTestCase(ApiTestBase):
|
||||||
|
def test_ping(self):
|
||||||
|
self._make_request('ping')
|
||||||
|
|
||||||
|
def test_get_license(self):
|
||||||
|
rv, child = self._make_request('getLicense', tag = 'license')
|
||||||
|
self.assertEqual(child.get('valid'), 'true')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
153
tests/api/test_user.py
Normal file
153
tests/api/test_user.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# vim:fenc=utf-8
|
||||||
|
#
|
||||||
|
# This file is part of Supysonic.
|
||||||
|
# Supysonic is a Python implementation of the Subsonic server API.
|
||||||
|
#
|
||||||
|
# Copyright (C) 2017 Alban 'spl0k' Féron
|
||||||
|
# 2017 Óscar García Amor
|
||||||
|
#
|
||||||
|
# Distributed under terms of the GNU AGPLv3 license.
|
||||||
|
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from .apitestbase import ApiTestBase
|
||||||
|
|
||||||
|
class UserTestCase(ApiTestBase):
|
||||||
|
def test_get_user(self):
|
||||||
|
# missing username
|
||||||
|
self._make_request('getUser', error = 10)
|
||||||
|
|
||||||
|
# non-existent user
|
||||||
|
self._make_request('getUser', { 'username': 'non existent' }, error = 70)
|
||||||
|
|
||||||
|
# self
|
||||||
|
rv, child = self._make_request('getUser', { 'username': 'alice' }, tag = 'user')
|
||||||
|
self.assertEqual(child.get('username'), 'alice')
|
||||||
|
self.assertEqual(child.get('adminRole'), 'true')
|
||||||
|
|
||||||
|
# other
|
||||||
|
rv, child = self._make_request('getUser', { 'username': 'bob' }, tag = 'user')
|
||||||
|
self.assertEqual(child.get('username'), 'bob')
|
||||||
|
self.assertEqual(child.get('adminRole'), 'false')
|
||||||
|
|
||||||
|
# self from non-admin
|
||||||
|
rv, child = self._make_request('getUser', { 'u': 'bob', 'p': 'B0b', 'username': 'bob' }, tag = 'user')
|
||||||
|
self.assertEqual(child.get('username'), 'bob')
|
||||||
|
self.assertEqual(child.get('adminRole'), 'false')
|
||||||
|
|
||||||
|
# other from non-admin
|
||||||
|
self._make_request('getUser', { 'u': 'bob', 'p': 'B0b', 'username': 'alice' }, error = 50)
|
||||||
|
|
||||||
|
def test_get_users(self):
|
||||||
|
# non-admin
|
||||||
|
self._make_request('getUsers', { 'u': 'bob', 'p': 'B0b' }, error = 50)
|
||||||
|
|
||||||
|
# admin
|
||||||
|
rv, child = self._make_request('getUsers', tag = 'users')
|
||||||
|
self.assertEqual(len(child), 2)
|
||||||
|
self.assertIsNotNone(self._find(child, "./user[@username='alice']"))
|
||||||
|
self.assertIsNotNone(self._find(child, "./user[@username='bob']"))
|
||||||
|
|
||||||
|
def test_create_user(self):
|
||||||
|
# non admin
|
||||||
|
self._make_request('createUser', { 'u': 'bob', 'p': 'B0b' }, error = 50)
|
||||||
|
|
||||||
|
# missing params, testing every combination, maybe overkill
|
||||||
|
self._make_request('createUser', error = 10)
|
||||||
|
self._make_request('createUser', { 'username': 'user' }, error = 10)
|
||||||
|
self._make_request('createUser', { 'password': 'pass' }, error = 10)
|
||||||
|
self._make_request('createUser', { 'email': 'email@example.com' }, error = 10)
|
||||||
|
self._make_request('createUser', { 'username': 'user', 'password': 'pass' }, error = 10)
|
||||||
|
self._make_request('createUser', { 'username': 'user', 'email': 'email@example.com' }, error = 10)
|
||||||
|
self._make_request('createUser', { 'password': 'pass', 'email': 'email@example.com' }, error = 10)
|
||||||
|
|
||||||
|
# duplicate
|
||||||
|
self._make_request('createUser', { 'username': 'bob', 'password': 'pass', 'email': 'me@bob.com' }, error = 0)
|
||||||
|
|
||||||
|
# test we only got our two initial users
|
||||||
|
rv, child = self._make_request('getUsers', tag = 'users')
|
||||||
|
self.assertEqual(len(child), 2)
|
||||||
|
|
||||||
|
# create users
|
||||||
|
self._make_request('createUser', { 'username': 'charlie', 'password': 'Ch4rl1e', 'email': 'unicorn@example.com', 'adminRole': True }, skip_post = True)
|
||||||
|
rv, child = self._make_request('getUser', { 'username': 'charlie' }, tag = 'user')
|
||||||
|
self.assertEqual(child.get('username'), 'charlie')
|
||||||
|
self.assertEqual(child.get('email'), 'unicorn@example.com')
|
||||||
|
self.assertEqual(child.get('adminRole'), 'true')
|
||||||
|
|
||||||
|
self._make_request('createUser', { 'username': 'dave', 'password': 'Dav3', 'email': 'dave@example.com' }, skip_post = True)
|
||||||
|
rv, child = self._make_request('getUser', { 'username': 'dave' }, tag = 'user')
|
||||||
|
self.assertEqual(child.get('username'), 'dave')
|
||||||
|
self.assertEqual(child.get('email'), 'dave@example.com')
|
||||||
|
self.assertEqual(child.get('adminRole'), 'false')
|
||||||
|
|
||||||
|
rv, child = self._make_request('getUsers', tag = 'users')
|
||||||
|
self.assertEqual(len(child), 4)
|
||||||
|
|
||||||
|
def test_delete_user(self):
|
||||||
|
# non admin
|
||||||
|
self._make_request('deleteUser', { 'u': 'bob', 'p': 'B0b', 'username': 'alice' }, error = 50)
|
||||||
|
|
||||||
|
# missing param
|
||||||
|
self._make_request('deleteUser', error = 10)
|
||||||
|
|
||||||
|
# non existing
|
||||||
|
self._make_request('deleteUser', { 'username': 'charlie' }, error = 70)
|
||||||
|
|
||||||
|
# test we still got our two initial users
|
||||||
|
rv, child = self._make_request('getUsers', tag = 'users')
|
||||||
|
self.assertEqual(len(child), 2)
|
||||||
|
|
||||||
|
# delete user
|
||||||
|
self._make_request('deleteUser', { 'username': 'bob' }, skip_post = True)
|
||||||
|
rv, child = self._make_request('getUsers', tag = 'users')
|
||||||
|
self.assertEqual(len(child), 1)
|
||||||
|
|
||||||
|
def test_change_password(self):
|
||||||
|
# missing parameter
|
||||||
|
self._make_request('changePassword', error = 10)
|
||||||
|
self._make_request('changePassword', { 'username': 'alice' }, error = 10)
|
||||||
|
self._make_request('changePassword', { 'password': 'newpass' }, error = 10)
|
||||||
|
|
||||||
|
# admin change self
|
||||||
|
self._make_request('changePassword', { 'username': 'alice', 'password': 'newpass' }, skip_post = True)
|
||||||
|
self._make_request('ping', error = 40)
|
||||||
|
self._make_request('ping', { 'u': 'alice', 'p': 'newpass' })
|
||||||
|
self._make_request('changePassword', { 'u': 'alice', 'p': 'newpass', 'username': 'alice', 'password': 'Alic3' }, skip_post = True)
|
||||||
|
|
||||||
|
# admin change other
|
||||||
|
self._make_request('changePassword', { 'username': 'bob', 'password': 'newbob' }, skip_post = True)
|
||||||
|
self._make_request('ping', { 'u': 'bob', 'p': 'B0b' }, error = 40)
|
||||||
|
self._make_request('ping', { 'u': 'bob', 'p': 'newbob' })
|
||||||
|
|
||||||
|
# non-admin change self
|
||||||
|
self._make_request('changePassword', { 'u': 'bob', 'p': 'newbob', 'username': 'bob', 'password': 'B0b' }, skip_post = True)
|
||||||
|
self._make_request('ping', { 'u': 'bob', 'p': 'newbob' }, error = 40)
|
||||||
|
self._make_request('ping', { 'u': 'bob', 'p': 'B0b' })
|
||||||
|
|
||||||
|
# non-admin change other
|
||||||
|
self._make_request('changePassword', { 'u': 'bob', 'p': 'B0b', 'username': 'alice', 'password': 'newpass' }, skip_post = True, error = 50)
|
||||||
|
self._make_request('ping', { 'u': 'alice', 'p': 'newpass' }, error = 40)
|
||||||
|
self._make_request('ping')
|
||||||
|
|
||||||
|
# change non existing
|
||||||
|
self._make_request('changePassword', { 'username': 'nonexsistent', 'password': 'pass' }, error = 70)
|
||||||
|
|
||||||
|
# non ASCII chars
|
||||||
|
self._make_request('changePassword', { 'username': 'alice', 'password': 'новыйпароль' }, skip_post = True)
|
||||||
|
self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' })
|
||||||
|
self._make_request('changePassword', { 'username': 'alice', 'password': 'Alic3', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True)
|
||||||
|
|
||||||
|
# non ASCII in hex encoded password
|
||||||
|
self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + binascii.hexlify('новыйпароль') }, skip_post = True)
|
||||||
|
self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' })
|
||||||
|
|
||||||
|
# new password starting with 'enc:' followed by non hex chars
|
||||||
|
self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:randomstring', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True)
|
||||||
|
self._make_request('ping', { 'u': 'alice', 'p': 'enc:randomstring' })
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
448
tests/assets/subsonic-rest-api-1.8.0.xsd
Normal file
448
tests/assets/subsonic-rest-api-1.8.0.xsd
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:sub="http://subsonic.org/restapi"
|
||||||
|
targetNamespace="http://subsonic.org/restapi"
|
||||||
|
attributeFormDefault="unqualified"
|
||||||
|
elementFormDefault="qualified"
|
||||||
|
version="1.8.0">
|
||||||
|
|
||||||
|
<xs:element name="subsonic-response" type="sub:Response"/>
|
||||||
|
|
||||||
|
<xs:complexType name="Response">
|
||||||
|
<xs:choice minOccurs="0" maxOccurs="1">
|
||||||
|
<xs:element name="musicFolders" type="sub:MusicFolders" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="indexes" type="sub:Indexes" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="directory" type="sub:Directory" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="artists" type="sub:ArtistsID3" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="artist" type="sub:ArtistWithAlbumsID3" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="album" type="sub:AlbumWithSongsID3" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="videos" type="sub:Videos" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="nowPlaying" type="sub:NowPlaying" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="searchResult" type="sub:SearchResult" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="searchResult2" type="sub:SearchResult2" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="searchResult3" type="sub:SearchResult3" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="playlists" type="sub:Playlists" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="playlist" type="sub:PlaylistWithSongs" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="jukeboxStatus" type="sub:JukeboxStatus" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="jukeboxPlaylist" type="sub:JukeboxPlaylist" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="license" type="sub:License" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="users" type="sub:Users" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="user" type="sub:User" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="chatMessages" type="sub:ChatMessages" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="albumList" type="sub:AlbumList" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="albumList2" type="sub:AlbumList2" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="randomSongs" type="sub:RandomSongs" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="lyrics" type="sub:Lyrics" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="podcasts" type="sub:Podcasts" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="shares" type="sub:Shares" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="starred" type="sub:Starred" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="starred2" type="sub:Starred2" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element name="error" type="sub:Error" minOccurs="1" maxOccurs="1"/>
|
||||||
|
</xs:choice>
|
||||||
|
<xs:attribute name="status" type="sub:ResponseStatus" use="required"/>
|
||||||
|
<xs:attribute name="version" type="sub:Version" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:simpleType name="ResponseStatus">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="ok"/>
|
||||||
|
<xs:enumeration value="failed"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="Version">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:pattern value="\d+\.\d+\.\d+"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:complexType name="MusicFolders">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="musicFolder" type="sub:MusicFolder" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="MusicFolder">
|
||||||
|
<xs:attribute name="id" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Indexes">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="shortcut" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="index" type="sub:Index" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/> <!-- Added in 1.7.0 -->
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="lastModified" type="xs:long" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Index">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Artist">
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="ArtistsID3">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="index" type="sub:IndexID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="IndexID3">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="ArtistID3">
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="albumCount" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="ArtistWithAlbumsID3">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:ArtistID3">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="AlbumID3">
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="artist" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="artistId" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="songCount" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="duration" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="created" type="xs:dateTime" use="required"/>
|
||||||
|
<xs:attribute name="starred" type="xs:dateTime" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="AlbumWithSongsID3">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:AlbumID3">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Videos">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="video" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Directory">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="child" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="parent" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Child">
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="parent" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="isDir" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="title" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="album" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="artist" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="track" type="xs:int" use="optional"/>
|
||||||
|
<xs:attribute name="year" type="xs:int" use="optional"/>
|
||||||
|
<xs:attribute name="genre" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="coverArt" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="size" type="xs:long" use="optional"/>
|
||||||
|
<xs:attribute name="contentType" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="suffix" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="transcodedContentType" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="transcodedSuffix" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="duration" type="xs:int" use="optional"/>
|
||||||
|
<xs:attribute name="bitRate" type="xs:int" use="optional"/>
|
||||||
|
<xs:attribute name="path" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="isVideo" type="xs:boolean" use="optional"/> <!-- Added in 1.4.1 -->
|
||||||
|
<xs:attribute name="userRating" type="sub:UserRating" use="optional"/> <!-- Added in 1.6.0 -->
|
||||||
|
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
|
||||||
|
<xs:attribute name="discNumber" type="xs:int" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
<xs:attribute name="created" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
<xs:attribute name="starred" type="xs:dateTime" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
<xs:attribute name="albumId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
<xs:attribute name="artistId" type="xs:string" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
<xs:attribute name="type" type="sub:MediaType" use="optional"/> <!-- Added in 1.8.0 -->
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:simpleType name="MediaType">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="music"/>
|
||||||
|
<xs:enumeration value="podcast"/>
|
||||||
|
<xs:enumeration value="audiobook"/>
|
||||||
|
<xs:enumeration value="video"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="UserRating">
|
||||||
|
<xs:restriction base="xs:int">
|
||||||
|
<xs:minInclusive value="1"/>
|
||||||
|
<xs:maxInclusive value="5"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="AverageRating">
|
||||||
|
<xs:restriction base="xs:double">
|
||||||
|
<xs:minInclusive value="1.0"/>
|
||||||
|
<xs:maxInclusive value="5.0"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:complexType name="NowPlaying">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="entry" type="sub:NowPlayingEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="NowPlayingEntry">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:Child">
|
||||||
|
<xs:attribute name="username" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="minutesAgo" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="playerId" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="playerName" type="xs:string" use="optional"/>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<!--Deprecated-->
|
||||||
|
<xs:complexType name="SearchResult">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="match" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="offset" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="totalHits" type="xs:int" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="SearchResult2">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="SearchResult3">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Playlists">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="playlist" type="sub:Playlist" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Playlist">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="name" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="comment" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
|
||||||
|
<xs:attribute name="owner" type="xs:string" use="optional"/> <!--Added in 1.8.0-->
|
||||||
|
<xs:attribute name="public" type="xs:boolean" use="optional"/> <!--Added in 1.8.0-->
|
||||||
|
<xs:attribute name="songCount" type="xs:int" use="required"/> <!--Added in 1.8.0-->
|
||||||
|
<xs:attribute name="duration" type="xs:int" use="required"/> <!--Added in 1.8.0-->
|
||||||
|
<xs:attribute name="created" type="xs:dateTime" use="required"/> <!--Added in 1.8.0-->
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="PlaylistWithSongs">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:Playlist">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="JukeboxStatus">
|
||||||
|
<xs:attribute name="currentIndex" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="playing" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="gain" type="xs:float" use="required"/>
|
||||||
|
<xs:attribute name="position" type="xs:int" use="optional"/> <!--Added in 1.7.0-->
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="JukeboxPlaylist">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:JukeboxStatus">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="ChatMessages">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="chatMessage" type="sub:ChatMessage" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="ChatMessage">
|
||||||
|
<xs:attribute name="username" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="time" type="xs:long" use="required"/>
|
||||||
|
<xs:attribute name="message" type="xs:string" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="AlbumList">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="AlbumList2">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="RandomSongs">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Lyrics" mixed="true">
|
||||||
|
<xs:attribute name="artist" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="title" type="xs:string" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Podcasts">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="channel" type="sub:PodcastChannel" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="PodcastChannel">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="episode" type="sub:PodcastEpisode" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="url" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="title" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="description" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
|
||||||
|
<xs:attribute name="errorMessage" type="xs:string" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="PodcastEpisode">
|
||||||
|
<xs:complexContent>
|
||||||
|
<xs:extension base="sub:Child">
|
||||||
|
<xs:attribute name="streamId" type="xs:string" use="optional"/> <!-- Use this ID for streaming the podcast. -->
|
||||||
|
<xs:attribute name="description" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="status" type="sub:PodcastStatus" use="required"/>
|
||||||
|
<xs:attribute name="publishDate" type="xs:dateTime" use="optional"/>
|
||||||
|
</xs:extension>
|
||||||
|
</xs:complexContent>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PodcastStatus">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="new"/>
|
||||||
|
<xs:enumeration value="downloading"/>
|
||||||
|
<xs:enumeration value="completed"/>
|
||||||
|
<xs:enumeration value="error"/>
|
||||||
|
<xs:enumeration value="deleted"/>
|
||||||
|
<xs:enumeration value="skipped"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:complexType name="Shares">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="share" type="sub:Share" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Share">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="entry" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="url" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="description" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="username" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="created" type="xs:dateTime" use="required"/>
|
||||||
|
<xs:attribute name="expires" type="xs:dateTime" use="optional"/>
|
||||||
|
<xs:attribute name="lastVisited" type="xs:dateTime" use="optional"/>
|
||||||
|
<xs:attribute name="visitCount" type="xs:int" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Starred">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:Artist" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="album" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Starred2">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="artist" type="sub:ArtistID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="album" type="sub:AlbumID3" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
<xs:element name="song" type="sub:Child" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="License">
|
||||||
|
<xs:attribute name="valid" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="email" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="key" type="xs:string" use="optional"/>
|
||||||
|
<xs:attribute name="date" type="xs:dateTime" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Users">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="user" type="sub:User" minOccurs="0" maxOccurs="unbounded"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="User">
|
||||||
|
<xs:attribute name="username" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="email" type="xs:string" use="optional"/> <!-- Added in 1.6.0 -->
|
||||||
|
<xs:attribute name="scrobblingEnabled" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
|
||||||
|
<xs:attribute name="adminRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="settingsRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="downloadRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="uploadRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="playlistRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="coverArtRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="commentRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="podcastRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="streamRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="jukeboxRole" type="xs:boolean" use="required"/>
|
||||||
|
<xs:attribute name="shareRole" type="xs:boolean" use="required"/> <!-- Added in 1.7.0 -->
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="Error">
|
||||||
|
<xs:attribute name="code" type="xs:int" use="required"/>
|
||||||
|
<xs:attribute name="message" type="xs:string" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
</xs:schema>
|
@ -1,235 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# vim:fenc=utf-8
|
|
||||||
#
|
|
||||||
# This file is part of Supysonic.
|
|
||||||
# Supysonic is a Python implementation of the Subsonic server API.
|
|
||||||
#
|
|
||||||
# Copyright (C) 2013-2017 Alban 'spl0k' Féron
|
|
||||||
# 2017 Óscar García Amor
|
|
||||||
#
|
|
||||||
# Distributed under terms of the GNU AGPLv3 license.
|
|
||||||
|
|
||||||
from supysonic import db
|
|
||||||
from supysonic.managers.user import UserManager
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
# Create an empty sqlite database in memory
|
|
||||||
store = db.get_store("sqlite:")
|
|
||||||
# Read schema from file
|
|
||||||
with open('schema/sqlite.sql') as sql:
|
|
||||||
schema = sql.read()
|
|
||||||
# Create tables on memory database
|
|
||||||
for command in schema.split(';'):
|
|
||||||
store.execute(command)
|
|
||||||
# Create some users
|
|
||||||
UserManager.add(store, 'alice', 'alice', 'test@example.com', True)
|
|
||||||
UserManager.add(store, 'bob', 'bob', 'bob@example.com', False)
|
|
||||||
UserManager.add(store, 'charlie', 'charlie', 'charlie@example.com', False)
|
|
||||||
|
|
||||||
# Create a mockup of web
|
|
||||||
from flask import Flask
|
|
||||||
app = Flask(__name__)
|
|
||||||
class web():
|
|
||||||
app = app
|
|
||||||
store = store
|
|
||||||
sys.modules['supysonic.web'] = web()
|
|
||||||
|
|
||||||
# Import module and set app in test mode
|
|
||||||
import supysonic.api
|
|
||||||
|
|
||||||
class ApiTestCase(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.app = app.test_client()
|
|
||||||
|
|
||||||
def test_get_license(self):
|
|
||||||
# GET user request
|
|
||||||
rv = self.app.get('/rest/getLicense.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
self.assertIn('license valid="true"', rv.data)
|
|
||||||
# POST user request
|
|
||||||
rv = self.app.post('/rest/getLicense.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
self.assertIn('license valid="true"', rv.data)
|
|
||||||
|
|
||||||
def test_get_user(self):
|
|
||||||
# GET missing username
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('message="Missing username"', rv.data)
|
|
||||||
# POST missing username
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('message="Missing username"', rv.data)
|
|
||||||
# GET non-admin request for other user
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=alice')
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# POST non-admin request for other user
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='bob', p='bob', c='test', username='alice'))
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# GET non-existent user
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=null')
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# POST non-existent user
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='null'))
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# GET admin request
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice')
|
|
||||||
self.assertIn('adminRole="true"', rv.data)
|
|
||||||
self.assertIn('username="alice"', rv.data)
|
|
||||||
# POST admin request
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='alice'))
|
|
||||||
self.assertIn('adminRole="true"', rv.data)
|
|
||||||
self.assertIn('username="alice"', rv.data)
|
|
||||||
# GET admin request for other user
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=bob')
|
|
||||||
self.assertIn('username="bob"', rv.data)
|
|
||||||
self.assertIn('adminRole="false"', rv.data)
|
|
||||||
# POST admin request for other user
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='bob'))
|
|
||||||
self.assertIn('username="bob"', rv.data)
|
|
||||||
self.assertIn('adminRole="false"', rv.data)
|
|
||||||
# GET non-admin request
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=charlie&p=charlie&c=test&username=charlie')
|
|
||||||
self.assertIn('username="charlie"', rv.data)
|
|
||||||
self.assertIn('adminRole="false"', rv.data)
|
|
||||||
# POST non-admin request
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='charlie', p='charlie', c='test', username='charlie'))
|
|
||||||
self.assertIn('username="charlie"', rv.data)
|
|
||||||
self.assertIn('adminRole="false"', rv.data)
|
|
||||||
|
|
||||||
def test_get_users(self):
|
|
||||||
# GET admin request
|
|
||||||
rv = self.app.get('/rest/getUsers.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('alice', rv.data)
|
|
||||||
self.assertIn('bob', rv.data)
|
|
||||||
self.assertIn('charlie', rv.data)
|
|
||||||
# POST admin request
|
|
||||||
rv = self.app.post('/rest/getUsers.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('alice', rv.data)
|
|
||||||
self.assertIn('bob', rv.data)
|
|
||||||
self.assertIn('charlie', rv.data)
|
|
||||||
# GET non-admin request
|
|
||||||
rv = self.app.get('/rest/getUsers.view?u=bob&p=bob&c=test')
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# POST non-admin request
|
|
||||||
rv = self.app.post('/rest/getUsers.view', data=dict(u='bob', p='bob', c='test'))
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
|
|
||||||
def test_create_user(self):
|
|
||||||
# GET non-admin request
|
|
||||||
rv = self.app.get('/rest/createUser.view?u=bob&p=bob&c=test')
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# POST non-admin request
|
|
||||||
rv = self.app.post('/rest/createUser.view', data=dict(u='bob', p='bob', c='test'))
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# GET incomplete request
|
|
||||||
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('message="Missing parameter"', rv.data)
|
|
||||||
# POST incomplete request
|
|
||||||
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('message="Missing parameter"', rv.data)
|
|
||||||
# GET create user and test that user is created
|
|
||||||
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=david&p=david&c=test&username=david')
|
|
||||||
self.assertIn('username="david"', rv.data)
|
|
||||||
self.assertIn('email="david@example.com"', rv.data)
|
|
||||||
self.assertIn('adminRole="true"', rv.data)
|
|
||||||
# POST create user and test that user is created
|
|
||||||
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='elanor', p='elanor', c='test', username='elanor'))
|
|
||||||
self.assertIn('username="elanor"', rv.data)
|
|
||||||
self.assertIn('email="elanor@example.com"', rv.data)
|
|
||||||
self.assertIn('adminRole="true"', rv.data)
|
|
||||||
# GET create duplicate
|
|
||||||
rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True')
|
|
||||||
self.assertIn('message="There is already a user with that username"', rv.data)
|
|
||||||
# POST create duplicate
|
|
||||||
rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True))
|
|
||||||
self.assertIn('message="There is already a user with that username"', rv.data)
|
|
||||||
|
|
||||||
def test_delete_user(self):
|
|
||||||
# GET non-admin request
|
|
||||||
rv = self.app.get('/rest/deleteUser.view?u=bob&p=bob&c=test')
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# POST non-admin request
|
|
||||||
rv = self.app.post('/rest/deleteUser.view', data=dict(u='bob', p='bob', c='test'))
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# GET incomplete request
|
|
||||||
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# POST incomplete request
|
|
||||||
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# GET delete non-existent user
|
|
||||||
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=nonexistent')
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# POST delete non-existent user
|
|
||||||
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='nonexistent'))
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# GET delete existent user
|
|
||||||
rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=elanor')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=elanor')
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
# POST delete existent user
|
|
||||||
rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='david'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='david'))
|
|
||||||
self.assertIn('message="Unknown user"', rv.data)
|
|
||||||
|
|
||||||
def test_change_password(self):
|
|
||||||
# GET incomplete request
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test')
|
|
||||||
self.assertIn('message="Missing parameter"', rv.data)
|
|
||||||
# POST incomplete request
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test'))
|
|
||||||
self.assertIn('message="Missing parameter"', rv.data)
|
|
||||||
# GET non-admin change own password
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# POST non-admin change own password
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# GET non-admin change other user password
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=alice&password=newpassword')
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# POST non-admin change other user password
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='bob', c='test', username='alice', password='newpassword'))
|
|
||||||
self.assertIn('message="Admin restricted"', rv.data)
|
|
||||||
# GET admin change other user password
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# POST admin change other user password
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# GET change non-existent user password
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test&username=nonexistent&password=nonexistent')
|
|
||||||
self.assertIn('message="No such user"', rv.data)
|
|
||||||
# POST change non-existent user password
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test', username='nonexistent', password='nonexistent'))
|
|
||||||
self.assertIn('message="No such user"', rv.data)
|
|
||||||
# GET non-admin change own password using extended utf-8 characters
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=новыйпароль')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# POST non-admin change own password using extended utf-8 characters
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='новыйпароль', c='test', username='bob', password='bob'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# GET non-admin change own password using extended utf-8 characters with old enc:
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=enc:626f62&c=test&username=bob&password=новыйпароль')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# POST non-admin change own password using extended utf-8 characters with old enc:
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:d0bdd0bed0b2d18bd0b9d0bfd0b0d180d0bed0bbd18c', c='test', username='bob', password='bob'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# GET non-admin change own password using enc: in password
|
|
||||||
rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=enc:test')
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
# POST non-admin change own password using enc: in password
|
|
||||||
rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:test', c='test', username='bob', password='bob'))
|
|
||||||
self.assertIn('status="ok"', rv.data)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
Loading…
Reference in New Issue
Block a user