add tests

This commit is contained in:
evilchili 2022-12-05 01:06:57 -08:00
parent d5934a99fb
commit 72dc85ac74
22 changed files with 236 additions and 80 deletions

View File

@ -1,6 +0,0 @@
HOST=127.0.0.1
DEBUG=1
USERNAME=test_username
PASSWORD=test_password
MEDIA_ROOT=/test
MEDIA_GLOB=*.flac,*.mp3

View File

@ -1,7 +1,8 @@
import logging
import os import os
from pathlib import Path 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'." _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." _reinstall_hint = "You might need to reinstall Groove On Demand to fix this error."
@ -12,11 +13,12 @@ def root():
if not path: if not path:
raise ConfigurationError(f"GROOVE_ON_DEMAND_ROOT is not defined in your environment.\n\n{_setup_hint}") raise ConfigurationError(f"GROOVE_ON_DEMAND_ROOT is not defined in your environment.\n\n{_setup_hint}")
path = Path(path).expanduser() path = Path(path).expanduser()
if not path.exists or not path.is_dir: if not path.exists() or not path.is_dir():
raise ConfigurationError( raise ConfigurationError(
"The Groove on Demand root directory (GROOVE_ON_DEMAND_ROOT) " "The Groove on Demand root directory (GROOVE_ON_DEMAND_ROOT) "
f"does not exist or isn't a directory.\n\n{_reinstall_hint}" f"does not exist or isn't a directory.\n\n{_reinstall_hint}"
) )
logging.debug(f"Root is {path}")
return Path(path) return Path(path)
@ -25,46 +27,63 @@ def media_root():
if not path: if not path:
raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}") raise ConfigurationError(f"MEDIA_ROOT is not defined in your environment.\n\n{_setup_hint}")
path = Path(path).expanduser() path = Path(path).expanduser()
if not path.exists and path.is_dir: if not path.exists() or not path.is_dir():
raise ConfigurationError( raise ConfigurationError(
"The media_root directory (MEDIA_ROOT) doesn't exist, or isn't a directory.\n\n{_setup_hint}" "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 return path
def media(relpath): def media(relpath):
return themes_root() / Path(relpath) path = media_root() / Path(relpath)
return path
def static_root(): def static_root():
dirname = os.environ.get('STATIC_PATH', 'static') dirname = os.environ.get('STATIC_PATH', 'static')
path = root() / Path(dirname) path = root() / Path(dirname)
if not path.exists or not path.is_dir: if not path.exists() or not path.is_dir():
raise ConfigurationError( raise ConfigurationError(
f"The static assets directory {dirname} (STATIC_PATH) " f"The static assets directory {dirname} (STATIC_PATH) "
f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}"
) )
logging.debug(f"Static root is {path}")
return path return path
def static(relpath): def static(relpath, theme=None):
return static_root() / Path(relpath) 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(): def themes_root():
dirname = os.environ.get('THEMES_PATH', 'themes') dirname = os.environ.get('THEMES_PATH', 'themes')
path = root() / Path(dirname) path = root() / Path(dirname)
if not path.exists or not path.is_dir: if not path.exists() or not path.is_dir():
raise ConfigurationError( raise ConfigurationError(
f"The themes directory {dirname} (THEMES_PATH) " f"The themes directory {dirname} (THEMES_PATH) "
f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}" f"doesn't exist, or isn't a directory.\n\n{_reinstall_hint}"
) )
logging.debug(f"Themes root is {path}")
return path return path
def theme(name): def theme(name):
path = themes_root() / Path(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) available = ','.join(available_themes)
raise ThemeMissingException( raise ThemeMissingException(
f"A theme directory named {name} does not exist or isn't a directory. " f"A theme directory named {name} does not exist or isn't a directory. "
@ -74,10 +93,6 @@ def theme(name):
return path return path
def theme_static(relpath):
return Path('static') / Path(relpath)
def theme_template(template_name): def theme_template(template_name):
return Path('templates') / Path(f"{template_name}.tpl") return Path('templates') / Path(f"{template_name}.tpl")

View File

@ -8,32 +8,38 @@ from groove.exceptions import ThemeConfigurationError, ConfigurationError
import groove.path 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): def load_theme(name=None):
name = os.environ.get('DEFAULT_THEME', name) name = name or os.environ.get('DEFAULT_THEME', None)
if not name: if not name: # pragma: no cover
raise ConfigurationError( raise ConfigurationError(
"It seems like DEFAULT_THEME is not set in your current environment.\n" "It seems like DEFAULT_THEME is not set in your current environment.\n"
"Running 'groove setup' may help you fix this problem." "Running 'groove setup' may help you fix this problem."
) )
theme_path = groove.path.theme(name) theme_path = groove.path.theme(name)
theme_info = _get_theme_info(theme_path) theme_info = _get_theme_info(theme_path)
try:
return Theme( return Theme(
name=name,
path=theme_path, path=theme_path,
**theme_info **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): def _get_theme_info(theme_path):
readme = theme_path / Path('README.md') readme = theme_path / Path('README.md')
if not readme.exists: if not readme.exists: # pragma: no cover
raise ThemeConfigurationError( raise ThemeConfigurationError(
"The theme is missing a required file: README.md.\n" "The theme is missing a required file: README.md.\n"
"Refer to the Groove On Demand documentation for help creating themes." "Refer to the Groove On Demand documentation for help creating themes."
) )
config = {'about': ''} config = {
'about': '',
}
with readme.open() as fh: with readme.open() as fh:
in_credits = False in_credits = False
in_block = False in_block = False
@ -55,7 +61,7 @@ def _get_theme_info(theme_path):
key = key.strip() key = key.strip()
value = value.strip() value = value.strip()
except ValueError: except ValueError:
logging.warn(f"Could not parse credits line: {line}") logging.warning(f"Could not parse credits line: {line}")
continue continue
logging.debug(f"Setting theme '{key}' to '{value}'.") logging.debug(f"Setting theme '{key}' to '{value}'.")
config[key] = value config[key] = value

View File

@ -5,6 +5,7 @@ import os
import bottle import bottle
from bottle import HTTPResponse, template, static_file from bottle import HTTPResponse, template, static_file
from bottle.ext import sqlalchemy from bottle.ext import sqlalchemy
from sqlalchemy.exc import NoResultFound, MultipleResultsFound
import groove.db import groove.db
from groove.auth import is_authenticated from groove.auth import is_authenticated
@ -54,54 +55,32 @@ def index():
return "Groovy." return "Groovy."
@server.route('/build')
@bottle.auth_basic(is_authenticated)
def build():
return "Authenticated. Groovy."
@server.route('/static/<filepath:path>') @server.route('/static/<filepath:path>')
def server_static(filepath): def serve_static(filepath):
theme = themes.load_theme() theme = themes.load_theme()
asset = theme.path / groove.path.theme_static(filepath) path = groove.path.static(filepath, theme=theme)
if asset.exists(): logging.debug(f"Serving asset {path.name} from {path.parent}")
root = asset.parent return static_file(path.name, root=path.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)
@server.route('/track/<request>/<track_id>') @server.route('/track/<request>/<track_id>')
def serve_track(request, track_id, db): def serve_track(request, track_id, db):
expected = requests.encode([track_id], '/track') 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") return HTTPResponse(status=404, body="Not found")
try:
track_id = int(track_id) track_id = int(track_id)
track = db.query(groove.db.track).filter( track = db.query(groove.db.track).filter(
groove.db.track.c.id == track_id groove.db.track.c.id == track_id
).one() ).one()
except (NoResultFound, MultipleResultsFound):
return HTTPResponse(status=404, body="Not found")
path = groove.path.media(track['relpath']) path = groove.path.media(track['relpath'])
if path.exists: logging.debug(f"Service track {path.name} from {path.parent}")
return static_file(path.name, root=path.parent) return static_file(path.name, root=path.parent)
else:
return HTTPResponse(status=404, body="Not found")
@server.route('/playlist/<slug>') @server.route('/playlist/<slug>')
@ -123,3 +102,21 @@ def serve_playlist(slug, db):
entry['url'] = f"/track/{sig}/{entry['track_id']}" entry['url'] = f"/track/{sig}/{entry['track_id']}"
return serve('playlist', playlist=pl) 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/<slug>')
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)

View File

@ -1,5 +1,5 @@
[pytest] [pytest]
env_override_existing_values = 1 env_override_existing_values = 1
env_files = .test_env env_files = test/fixtures/env
log_cli_level = DEBUG log_cli_level = DEBUG
addopts = --cov=groove/ --cov-report=term-missing addopts = --cov=groove/ --cov-report=term-missing

View File

@ -1,4 +1,8 @@
import pytest import pytest
import os
from pathlib import Path
from dotenv import load_dotenv
import groove.db import groove.db
@ -6,6 +10,20 @@ from sqlalchemy import create_engine, insert
from sqlalchemy.orm import sessionmaker 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') @pytest.fixture(scope='function')
def in_memory_engine(): def in_memory_engine():
return create_engine('sqlite:///:memory:', future=True) return create_engine('sqlite:///:memory:', future=True)
@ -32,9 +50,9 @@ def db(in_memory_db):
# create tracks # create tracks
query = insert(groove.db.track) query = insert(groove.db.track)
in_memory_db.execute(query, [ in_memory_db.execute(query, [
{'id': 1, 'relpath': '/UNKLE/Psyence Fiction/01 Guns Blazing (Drums of Death, Part 1).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': 2, 'relpath': 'UNKLE/Psyence Fiction/02 UNKLE (Main Title Theme).flac'},
{'id': 3, 'relpath': '/UNKLE/Psyence Fiction/03 Bloodstain.flac'} {'id': 3, 'relpath': 'UNKLE/Psyence Fiction/03 Bloodstain.flac'}
]) ])
# create playlists # create playlists

24
test/fixtures/env vendored Normal file
View File

@ -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

View File

@ -0,0 +1 @@
DRUMS OF DEATH YALL

0
test/fixtures/media/foo.mp3 vendored Normal file
View File

1
test/fixtures/static/favicon.ico vendored Normal file
View File

@ -0,0 +1 @@
favicon.ico

View File

View File

View File

View File

@ -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
```

View File

@ -0,0 +1 @@
/* test.css */

BIN
test/test.db Normal file

Binary file not shown.

13
test/test_path.py Normal file
View File

@ -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()

19
test/test_requests.py Normal file
View File

@ -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)

View File

@ -4,8 +4,10 @@ from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from sqlalchemy import func from sqlalchemy import func
import groove.exceptions
from groove.db import scanner, track from groove.db import scanner, track
fixture_tracks = [ 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 - 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", "/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): def test_scanner_no_media_root(in_memory_db):
del os.environ['MEDIA_ROOT'] 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) assert scanner.media_scanner(root=None, db=in_memory_db)

16
test/test_themes.py Normal file
View File

@ -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')

View File

@ -1,13 +1,12 @@
import pytest import pytest
import os
import sys import sys
import atheris import atheris
import bottle import bottle
from boddle import boddle from boddle import boddle
from unittest.mock import MagicMock
from groove import webserver from groove.webserver import webserver
def test_server(): def test_server():
@ -16,19 +15,58 @@ def test_server():
assert bottle.response.status_code == 200 assert bottle.response.status_code == 200
def test_auth_with_valid_credentials(): @pytest.mark.parametrize('track_id, expected', [
with boddle(auth=(os.environ.get('USERNAME'), os.environ.get('PASSWORD'))): ('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() webserver.build()
assert bottle.response.status_code == 200 assert bottle.response.status_code == 200
@pytest.mark.skip
def test_auth_random_input(): def test_auth_random_input():
def auth(fuzzed_input): def auth(fuzzed_input):
with boddle(auth=(fuzzed_input, fuzzed_input)): with boddle(auth=(fuzzed_input, fuzzed_input)):
response = webserver.build() response = webserver.build()
assert response.status_code == 401 assert response.status_code == 401
atheris.Setup([sys.argv[0], "-atheris_runs=100000"], auth) atheris.Setup([sys.argv[0], "-atheris_runs=100000"], auth)
try: try:
atheris.Fuzz() atheris.Fuzz()
@ -44,11 +82,11 @@ def test_auth_random_input():
]) ])
def test_playlist(db, slug, expected): def test_playlist(db, slug, expected):
with boddle(): with boddle():
response = webserver.get_playlist(slug, db) response = webserver.serve_playlist(slug, db)
assert response.status_code == expected assert response.status_code == expected
def test_playlist_on_empty_db(in_memory_db): def test_playlist_on_empty_db(in_memory_db):
with boddle(): 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 assert response.status_code == 404