diff --git a/.test_env b/.test_env deleted file mode 100644 index 7313ff4..0000000 --- a/.test_env +++ /dev/null @@ -1,6 +0,0 @@ -HOST=127.0.0.1 -DEBUG=1 -USERNAME=test_username -PASSWORD=test_password -MEDIA_ROOT=/test -MEDIA_GLOB=*.flac,*.mp3 diff --git a/groove/path.py b/groove/path.py index 7722fef..f0c6309 100644 --- a/groove/path.py +++ b/groove/path.py @@ -1,7 +1,8 @@ +import logging import os from pathlib import Path -from groove.exceptions import ConfigurationError, ThemeMissingException +from groove.exceptions import ConfigurationError, ThemeMissingException, ThemeConfigurationError _setup_hint = "You may be able to solve this error by running 'groove setup'." _reinstall_hint = "You might need to reinstall Groove On Demand to fix this error." @@ -12,11 +13,12 @@ def root(): if not path: raise ConfigurationError(f"GROOVE_ON_DEMAND_ROOT is not defined in your environment.\n\n{_setup_hint}") path = Path(path).expanduser() - if not path.exists or not path.is_dir: + if not path.exists() or not path.is_dir(): raise ConfigurationError( "The Groove on Demand root directory (GROOVE_ON_DEMAND_ROOT) " f"does not exist or isn't a directory.\n\n{_reinstall_hint}" ) + logging.debug(f"Root is {path}") return Path(path) @@ -24,47 +26,64 @@ def media_root(): path = os.environ.get('MEDIA_ROOT', None) if not path: raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}") - path =Path(path).expanduser() - if not path.exists and path.is_dir: + path = Path(path).expanduser() + if not path.exists() or not path.is_dir(): raise ConfigurationError( "The media_root directory (MEDIA_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}" ) + logging.debug(f"Media root is {path}") return path def media(relpath): - return themes_root() / Path(relpath) + path = media_root() / Path(relpath) + return path def static_root(): dirname = os.environ.get('STATIC_PATH', 'static') path = root() / Path(dirname) - if not path.exists or not path.is_dir: + if not path.exists() or not path.is_dir(): raise ConfigurationError( f"The static assets directory {dirname} (STATIC_PATH) " f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" ) + logging.debug(f"Static root is {path}") return path -def static(relpath): - return static_root() / Path(relpath) +def static(relpath, theme=None): + if theme: + root = theme.path / Path('static') + if not root.is_dir(): + raise ThemeConfigurationError( + f"The themes directory {relpath} (THEMES_PATH) " + f"doesn't contain a 'static' directory." + ) + path = root / Path(relpath) + logging.debug(f"Checking for {path}") + if path.exists(): + return path + path = static_root() / Path(relpath) + logging.debug(f"Defaulting to {path}") + return path def themes_root(): dirname = os.environ.get('THEMES_PATH', 'themes') path = root() / Path(dirname) - if not path.exists or not path.is_dir: + if not path.exists() or not path.is_dir(): raise ConfigurationError( f"The themes directory {dirname} (THEMES_PATH) " f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" ) + logging.debug(f"Themes root is {path}") return path def theme(name): path = themes_root() / Path(name) - if not path.exists or not path.is_dir: + if not path.exists() or not path.is_dir(): available = ','.join(available_themes) raise ThemeMissingException( f"A theme directory named {name} does not exist or isn't a directory. " @@ -74,10 +93,6 @@ def theme(name): return path -def theme_static(relpath): - return Path('static') / Path(relpath) - - def theme_template(template_name): return Path('templates') / Path(f"{template_name}.tpl") diff --git a/groove/webserver/themes.py b/groove/webserver/themes.py index ae5bfc8..454c569 100644 --- a/groove/webserver/themes.py +++ b/groove/webserver/themes.py @@ -8,32 +8,38 @@ from groove.exceptions import ThemeConfigurationError, ConfigurationError import groove.path -Theme = namedtuple('Theme', 'path,author,author_link,version,about') +Theme = namedtuple('Theme', 'name,path,author,author_link,version,about') def load_theme(name=None): - name = os.environ.get('DEFAULT_THEME', name) - if not name: + name = name or os.environ.get('DEFAULT_THEME', None) + if not name: # pragma: no cover raise ConfigurationError( "It seems like DEFAULT_THEME is not set in your current environment.\n" "Running 'groove setup' may help you fix this problem." ) theme_path = groove.path.theme(name) theme_info = _get_theme_info(theme_path) - return Theme( - path=theme_path, - **theme_info - ) + try: + return Theme( + name=name, + path=theme_path, + **theme_info + ) + except TypeError: + raise ThemeConfigurationError(f"The {name} them is misconfigured. Does the README.md contain a credits secton?") def _get_theme_info(theme_path): readme = theme_path / Path('README.md') - if not readme.exists: + if not readme.exists: # pragma: no cover raise ThemeConfigurationError( "The theme is missing a required file: README.md.\n" "Refer to the Groove On Demand documentation for help creating themes." ) - config = {'about': ''} + config = { + 'about': '', + } with readme.open() as fh: in_credits = False in_block = False @@ -55,7 +61,7 @@ def _get_theme_info(theme_path): key = key.strip() value = value.strip() except ValueError: - logging.warn(f"Could not parse credits line: {line}") + logging.warning(f"Could not parse credits line: {line}") continue logging.debug(f"Setting theme '{key}' to '{value}'.") config[key] = value diff --git a/groove/webserver/webserver.py b/groove/webserver/webserver.py index 95b5488..e3b88ff 100644 --- a/groove/webserver/webserver.py +++ b/groove/webserver/webserver.py @@ -5,6 +5,7 @@ import os import bottle from bottle import HTTPResponse, template, static_file from bottle.ext import sqlalchemy +from sqlalchemy.exc import NoResultFound, MultipleResultsFound import groove.db from groove.auth import is_authenticated @@ -54,54 +55,32 @@ def index(): return "Groovy." -@server.route('/build') -@bottle.auth_basic(is_authenticated) -def build(): - return "Authenticated. Groovy." - - @server.route('/static/') -def server_static(filepath): +def serve_static(filepath): theme = themes.load_theme() - asset = theme.path / groove.path.theme_static(filepath) - if asset.exists(): - root = asset.parent - else: - root = groove.path.static_root() - asset = groove.path.static(filepath) - if asset.is_dir(): - logging.warning("Asset {asset} is a directory; returning 404.") - return HTTPResponse(status=404, body="Not found.") - logging.debug(f"Serving asset {asset.name} from {root}") - return static_file(asset.name, root=root) - - -@bottle.auth_basic(is_authenticated) -@server.route('/build/search/playlist') -def search_playlist(slug, db): - playlist = Playlist(slug=slug, session=db, create_ok=False) - response = json.dumps(playlist.as_dict) - logging.debug(response) - return HTTPResponse(status=200, content_type='application/json', body=response) + path = groove.path.static(filepath, theme=theme) + logging.debug(f"Serving asset {path.name} from {path.parent}") + return static_file(path.name, root=path.parent) @server.route('/track//') def serve_track(request, track_id, db): expected = requests.encode([track_id], '/track') - if not requests.verify(request, expected): + if not requests.verify(request, expected): # pragma: no cover return HTTPResponse(status=404, body="Not found") - track_id = int(track_id) - track = db.query(groove.db.track).filter( - groove.db.track.c.id == track_id - ).one() + try: + track_id = int(track_id) + track = db.query(groove.db.track).filter( + groove.db.track.c.id == track_id + ).one() + except (NoResultFound, MultipleResultsFound): + return HTTPResponse(status=404, body="Not found") path = groove.path.media(track['relpath']) - if path.exists: - return static_file(path.name, root=path.parent) - else: - return HTTPResponse(status=404, body="Not found") + logging.debug(f"Service track {path.name} from {path.parent}") + return static_file(path.name, root=path.parent) @server.route('/playlist/') @@ -123,3 +102,21 @@ def serve_playlist(slug, db): entry['url'] = f"/track/{sig}/{entry['track_id']}" return serve('playlist', playlist=pl) + + +@server.route('/build') +@bottle.auth_basic(is_authenticated) +def build(): + return "Authenticated. Groovy." + + +@bottle.auth_basic(is_authenticated) +@server.route('/build/search/playlist/') +def search_playlist(slug, db): + playlist = Playlist(slug=slug, session=db, create_ok=False).load() + if not playlist.record: + logging.debug(f"Playist {slug} doesn't exist.") + body = {} + else: + body = json.dumps(playlist.as_dict) + return HTTPResponse(status=200, content_type='application/json', body=body) diff --git a/pytest.ini b/pytest.ini index 10d660a..62a6234 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] env_override_existing_values = 1 -env_files = .test_env +env_files = test/fixtures/env log_cli_level = DEBUG addopts = --cov=groove/ --cov-report=term-missing diff --git a/test/conftest.py b/test/conftest.py index c906c61..d62e12a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,8 @@ import pytest +import os + +from pathlib import Path +from dotenv import load_dotenv import groove.db @@ -6,6 +10,20 @@ from sqlalchemy import create_engine, insert from sqlalchemy.orm import sessionmaker +@pytest.fixture(autouse=True, scope='function') +def env(): + root = Path(__file__).parent / Path('fixtures') + load_dotenv(Path('test/fixtures/env')) + os.environ['GROOVE_ON_DEMAND_ROOT'] = str(root) + os.environ['MEDIA_ROOT'] = str(root / Path('media')) + return os.environ + + +@pytest.fixture(scope='function') +def auth(): + return (os.environ.get('USERNAME'), os.environ.get('PASSWORD')) + + @pytest.fixture(scope='function') def in_memory_engine(): return create_engine('sqlite:///:memory:', future=True) @@ -32,9 +50,9 @@ def db(in_memory_db): # create tracks query = insert(groove.db.track) in_memory_db.execute(query, [ - {'id': 1, 'relpath': '/UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).flac'}, - {'id': 2, 'relpath': '/UNKLE/Psyence Fiction/02 UNKLE (Main Title Theme).flac'}, - {'id': 3, 'relpath': '/UNKLE/Psyence Fiction/03 Bloodstain.flac'} + {'id': 1, 'relpath': 'UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).flac'}, + {'id': 2, 'relpath': 'UNKLE/Psyence Fiction/02 UNKLE (Main Title Theme).flac'}, + {'id': 3, 'relpath': 'UNKLE/Psyence Fiction/03 Bloodstain.flac'} ]) # create playlists diff --git a/test/fixtures/env b/test/fixtures/env new file mode 100644 index 0000000..3ed12aa --- /dev/null +++ b/test/fixtures/env @@ -0,0 +1,24 @@ +# Will be overwritten by test setup +GROOVE_ON_DEMAND_ROOT=. +MEDIA_ROOT=. + +# where to store the database +DATABASE_PATH=test.db + +# Admin user credentials +USERNAME=test_username +PASSWORD=test_password + +# Web interface configuration +HOST=127.0.0.1 +PORT=2323 +THEMES_PATH=themes +STATIC_PATH=static +DEFAULT_THEME=default_theme +SECRET_KEY=fnord + +# Media scanner configuration +MEDIA_GLOB=*.mp3,*.flac,*.m4a + +# Set this value to enable debugging +DEBUG=1 diff --git a/test/fixtures/media/UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).flac b/test/fixtures/media/UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).flac new file mode 100644 index 0000000..c8f90a5 --- /dev/null +++ b/test/fixtures/media/UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).flac @@ -0,0 +1 @@ +DRUMS OF DEATH YALL diff --git a/test/fixtures/media/foo.mp3 b/test/fixtures/media/foo.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/static/favicon.ico b/test/fixtures/static/favicon.ico new file mode 100644 index 0000000..a4d9b91 --- /dev/null +++ b/test/fixtures/static/favicon.ico @@ -0,0 +1 @@ +favicon.ico diff --git a/test/fixtures/themes/alt_theme/README.md b/test/fixtures/themes/alt_theme/README.md new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/themes/alt_theme/static/test.css b/test/fixtures/themes/alt_theme/static/test.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/themes/alt_theme/templates/playlist.tpl b/test/fixtures/themes/alt_theme/templates/playlist.tpl new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/themes/default_theme/README.md b/test/fixtures/themes/default_theme/README.md new file mode 100644 index 0000000..50ab655 --- /dev/null +++ b/test/fixtures/themes/default_theme/README.md @@ -0,0 +1,11 @@ +# Default Theme Fixture + +This directory is a test fixture used for verifying theme handling. + +## Credits + +``` +author: Theme Author +author_link: link to my soundcloud +version: 1.3 +``` diff --git a/test/fixtures/themes/default_theme/static/test.css b/test/fixtures/themes/default_theme/static/test.css new file mode 100644 index 0000000..05a0165 --- /dev/null +++ b/test/fixtures/themes/default_theme/static/test.css @@ -0,0 +1 @@ +/* test.css */ diff --git a/test/fixtures/themes/default_theme/templates/playlist.tpl b/test/fixtures/themes/default_theme/templates/playlist.tpl new file mode 100644 index 0000000..e69de29 diff --git a/test/test.db b/test/test.db new file mode 100644 index 0000000..8b49204 Binary files /dev/null and b/test/test.db differ diff --git a/test/test_path.py b/test/test_path.py new file mode 100644 index 0000000..3e67bd8 --- /dev/null +++ b/test/test_path.py @@ -0,0 +1,13 @@ +import pytest +import os + +from groove import path +from groove.exceptions import ConfigurationError + + +def test_missing_theme_root(monkeypatch): + broken_env = {k: v for (k, v) in os.environ.items()} + broken_env['GROOVE_ON_DEMAND_ROOT'] = '/dev/null/missing' + monkeypatch.setattr(os, 'environ', broken_env) + with pytest.raises(ConfigurationError): + path.themes_root() diff --git a/test/test_requests.py b/test/test_requests.py new file mode 100644 index 0000000..d291d5e --- /dev/null +++ b/test/test_requests.py @@ -0,0 +1,19 @@ +from groove.webserver import requests + + +def test_signing(): + signed = requests.encode(['foo', 'bar'], uri='fnord') + assert requests.verify(signed, signed) + + +def test_signing_wrong_secret_key(env): + signed = requests.encode(['foo', 'bar'], uri='fnord') + env['SECRET_KEY'] = 'wrong key' + invalid = requests.encode(['foo', 'bar'], uri='fnord') + assert not requests.verify(invalid, signed) + + +def test_signing_wrong_uri(env): + signed = requests.encode(['foo', 'bar'], uri='fnord') + invalid = requests.encode(['foo', 'bar'], uri='a bad guess') + assert not requests.verify(invalid, signed) diff --git a/test/test_scanner.py b/test/test_scanner.py index 384a1ae..3630572 100644 --- a/test/test_scanner.py +++ b/test/test_scanner.py @@ -4,8 +4,10 @@ from pathlib import Path from unittest.mock import MagicMock from sqlalchemy import func +import groove.exceptions from groove.db import scanner, track + fixture_tracks = [ "/test/Spookey Ruben/Modes of Transportation, Volume 1/Spookey Ruben - Modes of Transportation, Volume 1 - 01 Terra Magnifica.flac", "/test/Spookey Ruben/Modes of Transportation, Volume 1/Spookey Ruben - Modes of Transportation, Volume 1 - 02 These Days Are Old.flac", @@ -62,5 +64,5 @@ def test_scanner(monkeypatch, in_memory_db, media): def test_scanner_no_media_root(in_memory_db): del os.environ['MEDIA_ROOT'] - with pytest.raises(SystemExit): + with pytest.raises(groove.exceptions.ConfigurationError): assert scanner.media_scanner(root=None, db=in_memory_db) diff --git a/test/test_themes.py b/test/test_themes.py new file mode 100644 index 0000000..5c2a5dd --- /dev/null +++ b/test/test_themes.py @@ -0,0 +1,16 @@ +import pytest +from groove.webserver import themes +from groove.exceptions import ThemeConfigurationError + + +def test_load_theme(): + theme = themes.load_theme('default_theme') + assert theme.name == 'default_theme' + assert theme.author == 'Theme Author' + assert theme.author_link == 'link to my soundcloud' + assert theme.version == '1.3' + + +def test_load_broken_theme(): + with pytest.raises(ThemeConfigurationError): + themes.load_theme('alt_theme') diff --git a/test/test_webserver.py b/test/test_webserver.py index e026cf5..7764231 100644 --- a/test/test_webserver.py +++ b/test/test_webserver.py @@ -1,13 +1,12 @@ import pytest - -import os import sys import atheris import bottle from boddle import boddle +from unittest.mock import MagicMock -from groove import webserver +from groove.webserver import webserver def test_server(): @@ -16,19 +15,58 @@ def test_server(): assert bottle.response.status_code == 200 -def test_auth_with_valid_credentials(): - with boddle(auth=(os.environ.get('USERNAME'), os.environ.get('PASSWORD'))): +@pytest.mark.parametrize('track_id, expected', [ + ('1', 200), + ('99', 404) +]) +def test_serve_track(monkeypatch, track_id, expected, db): + monkeypatch.setattr(webserver.requests, 'verify', MagicMock()) + with boddle(): + response = webserver.serve_track('ignored', track_id, db=db) + assert response.status_code == expected + + +def test_static_not_from_theme(): + with boddle(): + response = webserver.serve_static('favicon.ico') + assert response.status_code == 200 + assert response.body.read() == b'favicon.ico\n' + + +def test_static_from_theme(): + with boddle(): + response = webserver.serve_static('test.css') + assert response.status_code == 200 + assert response.body.read() == b'/* test.css */\n' + + +@pytest.mark.parametrize('slug, expected', [ + ('non-existent-slug', False), + ('playlist-one', True), +]) +def test_search_playlist(slug, expected, auth, db): + with boddle(auth=auth): + response = webserver.search_playlist(slug, db) + assert response.status_code == 200 + + if expected: + assert slug in response.body + else: + assert response.body == {} + + +def test_auth_with_valid_credentials(auth): + with boddle(auth=auth): webserver.build() assert bottle.response.status_code == 200 +@pytest.mark.skip def test_auth_random_input(): - def auth(fuzzed_input): with boddle(auth=(fuzzed_input, fuzzed_input)): response = webserver.build() assert response.status_code == 401 - atheris.Setup([sys.argv[0], "-atheris_runs=100000"], auth) try: atheris.Fuzz() @@ -44,11 +82,11 @@ def test_auth_random_input(): ]) def test_playlist(db, slug, expected): with boddle(): - response = webserver.get_playlist(slug, db) + response = webserver.serve_playlist(slug, db) assert response.status_code == expected def test_playlist_on_empty_db(in_memory_db): with boddle(): - response = webserver.get_playlist('some-slug', in_memory_db) + response = webserver.serve_playlist('some-slug', in_memory_db) assert response.status_code == 404