diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index acddbf8e..69ce9fdb 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -1,18 +1,13 @@ +import logging, json, pathlib -from __future__ import unicode_literals - -import logging, os, json, pathlib -import tornado.web -import tornado.websocket - +import pkg_resources from mopidy import config, ext -from .frontend import IrisFrontend -from .handlers import WebsocketHandler, HttpHandler -from .core import IrisCore -from .mem import iris + +__version__ = pkg_resources.get_distribution("Mopidy-Iris").version logger = logging.getLogger(__name__) + ## # Core extension class # @@ -22,10 +17,10 @@ class Extension( ext.Extension ): dist_name = 'Mopidy-Iris' ext_name = 'iris' + version = __version__ def get_default_config(self): - conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return config.read(conf_file) + return config.read(pathlib.Path(__file__).parent / "ext.conf") def get_config_schema(self): schema = config.ConfigSchema(self.ext_name) @@ -39,7 +34,7 @@ class Extension( ext.Extension ): return schema def setup(self, registry): - + from .frontend import IrisFrontend # Add web extension registry.add('http:app', { 'name': self.ext_name, @@ -49,32 +44,19 @@ class Extension( ext.Extension ): # Add our frontend registry.add('frontend', IrisFrontend) -## -# Customised handler for react router URLS -# -# This routes all URLs to the same path, so that React can handle the path etc -## -class ReactRouterHandler(tornado.web.StaticFileHandler): - def initialize(self, path): - self.path = path - self.absolute_path = path - self.dirname, self.filename = os.path.split(path) - super(ReactRouterHandler, self).initialize(self.dirname) - - def get(self, path=None, include_body=True): - return super(ReactRouterHandler, self).get(self.path, include_body) - ## # Frontend factory ## def iris_factory(config, core): + from tornado.web import StaticFileHandler + from .handlers import HttpHandler, ReactRouterHandler, WebsocketHandler path = pathlib.Path(__file__).parent / 'static' return [ ( r'/http/([^/]*)', - handlers.HttpHandler, + HttpHandler, { 'core': core, 'config': config @@ -82,7 +64,7 @@ def iris_factory(config, core): ), ( r'/ws/?', - handlers.WebsocketHandler, + WebsocketHandler, { 'core': core, 'config': config @@ -90,21 +72,22 @@ def iris_factory(config, core): ), ( r'/assets/(.*)', - tornado.web.StaticFileHandler, + StaticFileHandler, { 'path': path / 'assets' } ), ( r'/((.*)(?:css|js|json|map)$)', - tornado.web.StaticFileHandler, + StaticFileHandler, { 'path': path } ), ( r'/(.*)', - ReactRouterHandler, { + ReactRouterHandler, + { 'path': path / 'index.html' } ), diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index 3fb2e6cb..056701c6 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -1,7 +1,4 @@ - -from __future__ import unicode_literals - -import random, string, logging, json, pykka, urllib, os, sys, mopidy_iris, subprocess +import random, string, logging, json, pathlib, pykka, urllib, os, sys, mopidy_iris, subprocess import tornado.web import tornado.ioloop import tornado.httpclient @@ -14,6 +11,7 @@ from pkg_resources import parse_version from tornado.escape import json_encode, json_decode from tornado.httpclient import AsyncHTTPClient +from . import Extension from .system import IrisSystemThread if sys.platform == 'win32': @@ -37,6 +35,11 @@ class IrisCore(pykka.ThreadingActor): "results": [] } + @classmethod + async def do_fetch(cls, client, request): + # This wrapper function exists to ease mocking. + return await client.fetch(request) + def setup(self, config, core): self.config = config self.core = core @@ -65,15 +68,11 @@ class IrisCore(pykka.ThreadingActor): # @return void ## def save_to_file(self, dict, name): - path = self.config['iris'].get('data_dir') - - # Create the folder if it doesn't yet exist - if not os.path.exists(path): - os.makedirs(path) + file_path = Extension.get_data_dir(self.config) / ('%s.pkl' % name) # And now open the file, and drop in our dict try: - with open(path + '/' + name + '.pkl', 'wb') as f: + with file_path.open('wb') as f: pickle.dump(dict, f, pickle.HIGHEST_PROTOCOL) except Exception: return False @@ -85,10 +84,10 @@ class IrisCore(pykka.ThreadingActor): # @return Dict ## def load_from_file(self, name): - path = self.config['iris'].get('data_dir') + file_path = Extension.get_data_dir(self.config) / ('%s.pkl' % name) try: - with open(path + '/' + name + '.pkl', 'rb') as f: + with file_path.open('wb') as f: return pickle.load(f) except Exception: return {} @@ -99,10 +98,9 @@ class IrisCore(pykka.ThreadingActor): # @return String ## def load_version(self): - filepath = os.path.join(os.path.dirname(__file__), '..', 'IRIS_VERSION') + file_path = pathlib.Path(__file__).parent.parent / 'IRIS_VERSION' try: - with open(filepath, 'r') as f: - return f.read() + return file_path.read_text() except Exception: return "Unknown" @@ -975,7 +973,7 @@ class IrisCore(pykka.ThreadingActor): else: return response - def refresh_spotify_token(self, *args, **kwargs): + async def refresh_spotify_token(self, *args, **kwargs): callback = kwargs.get('callback', None) # Use client_id and client_secret from config @@ -988,9 +986,9 @@ class IrisCore(pykka.ThreadingActor): } try: - http_client = tornado.httpclient.HTTPClient() + http_client = tornado.httpclient.AsyncHTTPClient() request = tornado.httpclient.HTTPRequest(url, method='POST', body=urllib.parse.urlencode(data)) - response = http_client.fetch(request) + response = await self.do_fetch(http_client, request) token = json.loads(response.body) token['expires_at'] = time.time() + token['expires_in'] diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index 831a4183..6ba3362c 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -1,5 +1,3 @@ - -from __future__ import unicode_literals from mopidy.core import CoreListener from .core import IrisCore @@ -13,7 +11,7 @@ logger = logging.getLogger(__name__) class IrisFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): - super(IrisFrontend, self).__init__() + super().__init__() # Pass our Mopidy config and core to the IrisCore instance iris.config = config diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py index b625f135..18f327c1 100755 --- a/mopidy_iris/handlers.py +++ b/mopidy_iris/handlers.py @@ -1,5 +1,3 @@ - -from __future__ import unicode_literals from datetime import datetime from tornado.escape import json_encode, json_decode import tornado.ioloop, tornado.web, tornado.websocket, tornado.template @@ -235,4 +233,19 @@ class HttpHandler(tornado.web.RequestHandler): self.finish() +## +# Customised handler for react router URLS +# +# This routes all URLs to the same path, so that React can handle the path etc +## +class ReactRouterHandler(tornado.web.StaticFileHandler): + def initialize(self, path): + self.path = path + self.absolute_path = path + self.dirname = path.parent + self.filename = path.name + super().initialize(self.dirname) + + def get(self, path=None, include_body=True): + return super().get(self.path, include_body) diff --git a/mopidy_iris/system.py b/mopidy_iris/system.py index c9b435bf..28f52292 100755 --- a/mopidy_iris/system.py +++ b/mopidy_iris/system.py @@ -1,17 +1,47 @@ - from threading import Thread -import os, logging, subprocess, json +import logging, os, pathlib, subprocess, json # import logger logger = logging.getLogger(__name__) + +class IrisSystemError(Exception): + pass + + +class IrisSystemPermissionError(IrisSystemError): + reason = "Permission denied" + + def __init__(self, path): + message = "Password-less access to %s was refused. Check your /etc/sudoers file." % path.as_uri() + logger.error(message) + super().__init__(message) + + class IrisSystemThread(Thread): + _USE_SUDO = True + def __init__(self, action, callback): Thread.__init__(self) self.action = action self.callback = callback - self.path = os.path.dirname(__file__) + self.script_path = pathlib.Path(__file__).parent / "system.sh" + def get_command(self, action=None, *, non_interactive=False): + if self._USE_SUDO: + if non_interactive: + args = [b'sudo -n'] + else: + args = [b'sudo'] + else: + args = [] + + if action is None: + action = self.action + + args = args + [bytes(self.script_path), action.encode()] + return args + ## # Run the defined action ## @@ -20,34 +50,32 @@ class IrisSystemThread(Thread): try: self.can_run() - except Exception as e: + except IrisSystemError as e: logger.error(e) error = { - 'message': "Permission denied", - 'description': str(e) + 'message': e.reason, + 'description': e.message } return { 'error': error } - logger.debug("sudo "+ self.path +"/system.sh "+ self.action) - - proc = subprocess.Popen(["sudo", self.path+"/system.sh", self.action], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + command = self.get_command() + logger.debug("Running '%s'", os.fsdecode(b' '.join(command))) + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout,stderr = proc.communicate() if stderr: - logger.error(stderr.decode()) - self.callback(None, { 'error': stderr.decode() }) + error_string = os.fsdecode(stderr) + logger.error(error_string) + self.callback(None, {'error': error_string}) else: - logger.info(stdout.decode()) - self.callback({ 'output': stdout.decode() }, None) - - + response_string = os.fsdecode(stdout) + logger.info(response_string) + self.callback({'output': response_string}, None) ## # Check if we have access to the system script (system.sh) @@ -55,14 +83,14 @@ class IrisSystemThread(Thread): # @return boolean or exception ## def can_run(self, *args, **kwargs): - # Attempt an empty call to our system file - process = subprocess.Popen("sudo -n "+self.path+"/system.sh check", stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + command_bytes = b' '.join(self.get_command('check', non_interactive=True)) + process = subprocess.Popen(command_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) result, error = process.communicate() exitCode = process.wait() # Some kind of failure, so we can't run any commands this way if exitCode > 0: - raise Exception("Password-less access to "+self.path+"/system.sh was refused. Check your /etc/sudoers file.") + raise IrisSystemPermissionError(self.script_path) else: return True diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 00000000..26a2cdbc --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,98 @@ +import pytest +from asyncio import Future +from unittest import mock + +import tornado.testing +import tornado.web +from tornado.httpclient import HTTPResponse +from tornado.escape import json_decode + +from mopidy_iris import handlers +from mopidy_iris import core +from mopidy_iris.mem import iris + + +def async_return_helper(result): + f = Future() + f.set_result(result) + return f + + +class HttpHandlerTest(tornado.testing.AsyncHTTPTestCase): + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def get_app(self): + http_handler = handlers.HttpHandler + # http_handler.handle_result = mock.Mock() + # self.handler_mock = http_handler.handle_result + return tornado.web.Application( + [ + ( + r"/(.*)", + http_handler, + { + 'core': None, + 'config': {}, + }, + ) + ] + ) + + def test_get_method(self): + with mock.patch("time.time", return_value=100): + response = self.fetch("/test", method="GET") + + assert 200 == response.code + result = json_decode(response.body) + assert "2.0" == result["jsonrpc"] + assert "test" == result["method"] + assert 100 == result["id"] + assert "Running test... please wait" == result["result"]["message"] + + def test_get_method_headers(self): + response = self.fetch("/test", method="GET") + + assert response.headers["Access-Control-Allow-Origin"] == "*" + assert "Origin" in response.headers["Access-Control-Allow-Headers"] + + def test_get_unknown_method_is_error(self): + response = self.fetch("/baz", method="GET") + + assert 400 == response.code + error = json_decode(response.body)["error"] + assert "Method baz does not exist" == error["message"] + + @mock.patch.object(handlers, "iris") + def test_get_method_called(self, iris_mock): + iris_mock.foo = mock.Mock() + + response = self.fetch("/foo", method="GET") + + iris_mock.foo.assert_called_once() + assert 200 == response.code + + @mock.patch.object(handlers, "iris") + def test_get_method_any_exception_handled(self, iris_mock): + iris_mock.foo = mock.Mock(side_effect=Exception("bar")) + + response = self.fetch("/foo", method="GET") + + iris_mock.foo.assert_called_once() + assert 200 == response.code + assert "bar" in self._caplog.text + + @mock.patch.object(handlers.iris, "do_fetch") + def test_get_method_with_fetch(self, fetch_mock): + iris.config = {"spotify" : {"client_id": 123, "client_secret": 456}} + result = mock.Mock(spec=HTTPResponse, body='{"expires_in":88}') + fetch_mock.return_value = async_return_helper(result) + + response = self.fetch("/refresh_spotify_token", method="GET") + + assert 200 == response.code + assert len(response.body) > 0 + result = json_decode(response.body) + assert "refresh_spotify_token" == result["method"] + assert 88 == result["result"]["spotify_token"]["expires_in"] diff --git a/tests/test_system.py b/tests/test_system.py new file mode 100644 index 00000000..5fd08206 --- /dev/null +++ b/tests/test_system.py @@ -0,0 +1,86 @@ +import pathlib, pytest, subprocess +from unittest import mock + +from mopidy_iris.system import IrisSystemThread, IrisSystemPermissionError + + +def test_system_sh_path(): + iris_system = IrisSystemThread('foo', None) + assert iris_system.script_path.is_file() + assert iris_system.script_path.name == "system.sh" + + +def test_can_run(): + iris_system = IrisSystemThread('foo', None) + iris_system._USE_SUDO = False + assert iris_system.can_run() is True + +@pytest.fixture +def popen_mock(): + patcher = mock.patch("subprocess.Popen", spec=True) + yield patcher.start() + patcher.stop() + +@pytest.fixture +def process_mock(popen_mock): + mock_process = popen_mock.return_value + mock_process.communicate.return_value = ('', None) + mock_process.wait.return_value = 0 + yield mock_process + +def test_can_run_args(popen_mock, process_mock): + IrisSystemThread('foo', None).can_run() + popen_mock.assert_called_once_with( + mock.ANY, + shell=True, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE + ) + +def test_can_run_uses_sudo_non_interactive(popen_mock, process_mock): + IrisSystemThread('foo', None).can_run() + + popen_mock.assert_called_once() + assert popen_mock.call_args[0][0].startswith(b"sudo -n ") + + +def test_can_run_calls_script_check(popen_mock, process_mock): + IrisSystemThread('foo', None).can_run() + + assert popen_mock.call_args[0][0].endswith(b"system.sh check") + + +def test_can_run_sudo_refused_raises(popen_mock, process_mock, caplog): + process_mock.wait.return_value = 1 + iris_system = IrisSystemThread('foo', None) + + with pytest.raises(IrisSystemPermissionError) as excinfo: + iris_system.can_run() + + error_message = ( + "Password-less access to %s was refused. " + "Check your /etc/sudoers file." % iris_system.script_path.as_uri() + ) + assert error_message in str(excinfo.value) + assert error_message in caplog.text + + +def test_run_args(popen_mock, process_mock): + iris_system = IrisSystemThread('foo', mock.Mock()) + iris_system.can_run = mock.Mock(return_value = True) + iris_system.run() + + popen_mock.assert_called_once_with( + mock.ANY, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE + ) + + +def test_run_uses_sudo(popen_mock, process_mock): + iris_system = IrisSystemThread('foo', mock.Mock()) + iris_system.can_run = mock.Mock(return_value = True) + iris_system.run() + + popen_mock.assert_called_once() + assert popen_mock.call_args[0][0][0] == b"sudo"