diff --git a/.travis.yml b/.travis.yml index fe2ec78..14a751d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ script: - xvfb-run --server-args="-screen 0, 1280x1280x16" tests/python/coverage_run.sh after_success: - - coveralls + - codecov notifications: email: diff --git a/dev/commands.py b/dev/commands.py index 5d9b041..54d8a9d 100755 --- a/dev/commands.py +++ b/dev/commands.py @@ -9,19 +9,21 @@ sys.path.insert(1, os.path.join(sys.path[0], '..')) -def createdb(): +def createdb(ensure=True): """Create the schema and tables and return a Session.""" from minigrid import models engine = models.create_engine() - models.Base.metadata.create_all(engine) - return sessionmaker(bind=engine, autocommit=True)() + if ensure: + models.Base.metadata.create_all(engine) + print('Created schema {}'.format(models.Base.metadata.schema)) + return sessionmaker(bind=engine)() def create_user(*, email): """Create a user with the given e-mail.""" from minigrid import models - session = createdb() - with session.begin(): + session = createdb(ensure=False) + with session.begin_nested(): session.add(models.User(email=email)) print('Created user with e-mail ' + email) @@ -47,7 +49,7 @@ def main(): from minigrid.options import parse_command_line parse_command_line([None] + others) from minigrid.options import options - if 'db_schema' not in others: + if not any(other.startswith('--db_schema=') for other in others): options.db_schema = 'minigrid_dev' try: command = globals()[args.command_name] diff --git a/dev/requirements.txt b/dev/requirements.txt index 0e4d80f..f562de2 100644 --- a/dev/requirements.txt +++ b/dev/requirements.txt @@ -1,3 +1,4 @@ +codecov fakeredis==0.8.1 flake8==3.2.1 pydocstyle==1.1.1 diff --git a/minigrid/error.py b/minigrid/error.py index 33f9054..d17201a 100644 --- a/minigrid/error.py +++ b/minigrid/error.py @@ -21,7 +21,7 @@ class LoginError(MinigridHTTPError): def __init__(self, status_code=400, - template_name='index.html', + template_name='index-logged-out.html', *, reason): """Create a login error (400 by default).""" super().__init__(reason, status_code, template_name) diff --git a/minigrid/handlers.py b/minigrid/handlers.py index fa665e7..424f854 100644 --- a/minigrid/handlers.py +++ b/minigrid/handlers.py @@ -3,6 +3,7 @@ from urllib.parse import urlencode from uuid import uuid4 +from sqlalchemy.exc import DataError, IntegrityError from sqlalchemy.orm.exc import NoResultFound import tornado.web @@ -18,7 +19,7 @@ class BaseHandler(tornado.web.RequestHandler): @property def session(self): - """The database session. Use session.begin() for transactions.""" + """The db session. Use session.begin_nested() for transactions.""" return self.application.session def get_current_user(self): @@ -46,7 +47,13 @@ class MainHandler(BaseHandler): def get(self): """Render the homepage.""" - self.render('index.html', reason=None) + if self.current_user: + minigrids = ( + self.session + .query(models.Minigrid).order_by(models.Minigrid.name)) + self.render('index-minigrid-list.html', minigrids=minigrids) + return + self.render('index-logged-out.html', reason=None) def post(self): """Send login information to the portier broker.""" @@ -64,6 +71,52 @@ def post(self): self.redirect('https://broker.portier.io/auth?' + query_args) +class UsersHandler(BaseHandler): + """Handlers for user management.""" + + def _render_users(self, reason=None): + users = self.session.query(models.User).order_by('email') + self.render('users.html', users=users, reason=reason) + + @tornado.web.authenticated + def get(self): + """Render the view for user management.""" + self._render_users() + + @tornado.web.authenticated + def post(self): + """Create a new user model.""" + email = self.get_argument('email') + reason = None + try: + with self.session.begin_nested(): + self.session.add(models.User(email=email)) + except IntegrityError as error: + if 'user_email_check' in error.orig.pgerror: + reason = '{} is not a valid e-mail address'.format(email) + else: + reason = 'Account for {} already exists'.format(email) + self._render_users(reason=reason) + + +class MinigridHandler(BaseHandler): + """Handlers for a minigrid view.""" + + @tornado.web.authenticated + def get(self, minigrid_id): + """Render the view for a minigrid record.""" + try: + minigrid = ( + self.session + .query(models.Minigrid) + .filter_by(minigrid_id=minigrid_id) + .one() + ) + except (NoResultFound, DataError): + raise tornado.web.HTTPError(404) + self.render('minigrid.html', minigrid=minigrid) + + class VerifyLoginHandler(BaseHandler): """Handlers for portier verification.""" @@ -115,6 +168,8 @@ def post(self): application_urls = [ (r'/', MainHandler), + (r'/minigrid/(.+)?', MinigridHandler), + (r'/users/?', UsersHandler), (r'/verify/?', VerifyLoginHandler), (r'/logout/?', LogoutHandler), ] diff --git a/minigrid/models.py b/minigrid/models.py index a30bbf9..15fb13b 100644 --- a/minigrid/models.py +++ b/minigrid/models.py @@ -2,6 +2,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql as pg from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.sql.functions import current_timestamp from sqlalchemy.sql import func from minigrid.options import options @@ -36,6 +37,25 @@ def pk(): pg.UUID, primary_key=True, server_default=func.uuid_generate_v4()) +def update_time(): + """Return a timestamp column set to CURRENT_TIMESTAMP by default.""" + return sa.Column( + pg.TIMESTAMP(timezone=True), + nullable=False, + server_default=current_timestamp(), + ) + + +def json_column(column_name, default=None): + """Return a JSONB column that is a dictionary at the top level.""" + return sa.Column( + pg.json.JSONB, + sa.CheckConstraint("{} @> '{{}}'".format(column_name)), + nullable=False, + server_default=default, + ) + + class User(Base): """The model for a registered user.""" @@ -45,3 +65,23 @@ class User(Base): pg.TEXT, sa.CheckConstraint("email ~ '.*@.*'"), nullable=False, unique=True, ) + + +class Minigrid(Base): + """The model for a minigrid.""" + + __tablename__ = 'minigrid' + minigrid_id = pk() + name = sa.Column( + pg.TEXT, sa.CheckConstraint("name != ''"), + nullable=False, unique=True) + day_tariff = sa.Column( + pg.NUMERIC, + sa.CheckConstraint('day_tariff > 0'), nullable=False) + day_tariff_update_time = update_time() + night_tariff = sa.Column( + pg.NUMERIC, + sa.CheckConstraint('night_tariff > 0'), nullable=False) + night_tariff_update_time = update_time() + error_code = json_column('error_code', default='{}') + status = json_column('status', default='{}') diff --git a/minigrid/templates/index-logged-out.html b/minigrid/templates/index-logged-out.html new file mode 100644 index 0000000..b09f7ce --- /dev/null +++ b/minigrid/templates/index-logged-out.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block body %} + {% if reason is not None %} +

Login unsuccessful: {{ reason }}

+ {% end %} +
+ {% module xsrf_form_html() %} + E-mail: + +
+{% end %} diff --git a/minigrid/templates/index-minigrid-list.html b/minigrid/templates/index-minigrid-list.html new file mode 100644 index 0000000..739589b --- /dev/null +++ b/minigrid/templates/index-minigrid-list.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block body %} +

Manage users »

+

Minigrids:

+ +{% end %} diff --git a/minigrid/templates/index.html b/minigrid/templates/index.html deleted file mode 100644 index 3c90996..0000000 --- a/minigrid/templates/index.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'base.html' %} - -{% block body %} -

This is a page.

- {% if reason is not None %} -

Login unsuccessful: {{ reason }}

- {% end %} - {% if current_user is None %} -
- {% module xsrf_form_html() %} - E-mail: - -
- {% end %} -{% end %} diff --git a/minigrid/templates/minigrid.html b/minigrid/templates/minigrid.html new file mode 100644 index 0000000..f259402 --- /dev/null +++ b/minigrid/templates/minigrid.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block body %} +

Minigrid Name: {{ minigrid.name }}

+

ID: {{ minigrid.minigrid_id }}

+

Day tariff: {{ minigrid.day_tariff }}

+

Last update of day tariff: {{ minigrid.day_tariff_update_time }}

+

Night tariff: {{ minigrid.night_tariff }}

+

Last update of night tariff: {{ minigrid.night_tariff_update_time }}

+

Error code: {{ minigrid.error_code }}

+

Status: {{ minigrid.status }}

+ +{% end %} diff --git a/minigrid/templates/users.html b/minigrid/templates/users.html new file mode 100644 index 0000000..a33fec6 --- /dev/null +++ b/minigrid/templates/users.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} + +{% block body %} +

Users:

+ +

Add user account:

+
+ {% module xsrf_form_html() %} + E-mail: + +
+ {% if reason is not None %} +

Could not create user account: {{ reason }}

+ {% end %} + +{% end %} diff --git a/prod/create_initial_user.py b/prod/create_initial_user.py new file mode 100755 index 0000000..7826e66 --- /dev/null +++ b/prod/create_initial_user.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +"""Create the initial administrator for the application.""" +import argparse +import os +import sys +from time import sleep + +from sqlalchemy import func +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import sessionmaker + +sys.path.insert(1, os.path.join(sys.path[0], '..')) + + +def main(): + """Supply the administrator's e-mail""" + parser = argparse.ArgumentParser() + parser.add_argument('email') + args, others = parser.parse_known_args() + from minigrid.options import parse_command_line + parse_command_line([None] + others) + from minigrid.options import options + if not any(other.startswith('--db_schema=') for other in others): + options.db_schema = 'minigrid' + from minigrid import models + engine = models.create_engine() + session = sessionmaker(bind=engine)() + try: + users = session.query(func.count(models.User.user_id)).scalar() + except OperationalError: + print('Database connection failed... trying again in 5 seconds.') + sleep(5) + users = session.query(func.count(models.User.user_id)).scalar() + if users: + print('At least one user already exists. Log in as that user.') + sys.exit(1) + with session.begin_nested(): + session.add(models.User(email=args.email)) + print('Created initial user with e-mail', args.email) + + +if __name__ == '__main__': + main() diff --git a/prod/docker-compose.yml b/prod/docker-compose.yml index fb848f8..9c4fe41 100644 --- a/prod/docker-compose.yml +++ b/prod/docker-compose.yml @@ -1,7 +1,7 @@ version: '2' services: minigrid: - image: selcolumbia/minigrid-server:0.0.7 + image: selcolumbia/minigrid-server:0.1.1 command: ./prod/run.sh --db_host=db --redis_url=redis://redis:6379/0 --minigrid-website-url=https://www.example.com depends_on: - redis diff --git a/prod/install.sh b/prod/install.sh index d943a21..0d3ab4e 100755 --- a/prod/install.sh +++ b/prod/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env sh -# Minigrid Server installer for version 0.0.7 +# Minigrid Server installer for version 0.1.1 set -e # Do you have docker installed? @@ -106,14 +106,21 @@ $SUDO openssl dhparam -out /etc/letsencrypt/live/$LETSENCRYPT_DIR/dhparam.pem 20 # Download the configuration files printf "========================================\n" -printf " Downloading configuration files \n" +printf " Generating configuration \n" printf "========================================\n" -$CURL -L https://raw.githubusercontent.com/SEL-Columbia/minigrid-server/0.0.7/prod/docker-compose.yml > docker-compose.yml -$CURL -L https://raw.githubusercontent.com/SEL-Columbia/minigrid-server/0.0.7/prod/nginx.conf > nginx.conf +$CURL -L https://raw.githubusercontent.com/SEL-Columbia/minigrid-server/0.1.1/prod/docker-compose.yml > docker-compose.yml +$CURL -L https://raw.githubusercontent.com/SEL-Columbia/minigrid-server/0.1.1/prod/nginx.conf > nginx.conf sed -i s/www.example.com/$LETSENCRYPT_DIR/g docker-compose.yml sed -i s/www.example.com/$LETSENCRYPT_DIR/g nginx.conf +printf "\n" +printf "Please enter an e-mail address for the \n" +printf "administrator. This will be the only \n" +printf "account that can log in at first. \n" +printf "Administrator e-mail address:\n>>> " +read ADMIN_EMAIL + # Bring up the server printf "========================================\n" printf " Starting minigrid server. \n" @@ -126,7 +133,10 @@ if [ -f /etc/redhat-release ] ; then chcon -Rt svirt_sandbox_file_t . fi $DOCKER_COMPOSE up -d -NGINX_CONTAINER_NAME=$($DOCKER_COMPOSE ps | grep nginx | cut -d' ' -f1) +MINIGRID_CONTAINER_NAME=$($DOCKER_COMPOSE ps | grep _minigrid_ | cut -d' ' -f1) +sleep 1 +docker exec $MINIGRID_CONTAINER_NAME ""prod/create_initial_user.py --db-host=db $ADMIN_EMAIL"" +NGINX_CONTAINER_NAME=$($DOCKER_COMPOSE ps | grep _nginx_ | cut -d' ' -f1) # Let's Encrypt auto-renew (for now this is a cron job). printf "========================================\n" @@ -146,6 +156,6 @@ CRON_CMD="mkdir -p /tmp/letsencrypt && "\ "if [ -f /tmp/renewed ] ; then docker restart $NGINX_CONTAINER_NAME ; fi ; "\ "rm -f /tmp/renewed" # https://certbot.eff.org/#ubuntuxenial-nginx recommends running this twice a day on random minute within the hour -CRON_JOB="00 01,13 * * * sleep \$(expr \$RANDOM \% 59); $CRON_CMD" +CRON_JOB="00 01,13 * * * sleep \$(expr \$RANDOM \% 59 \* 60); $CRON_CMD" crontab -l | fgrep -i -v "$CRON_CMD" | { cat; echo "$CRON_JOB"; } | crontab - crontab -l diff --git a/server.py b/server.py index e5fa33d..e53a02c 100644 --- a/server.py +++ b/server.py @@ -37,7 +37,7 @@ def __init__(self, session=None, **kwargs): 'Database connection failed... trying again in 5 seconds.') sleep(5) models.Base.metadata.create_all(engine) - Session = sessionmaker(bind=engine, autocommit=True) + Session = sessionmaker(bind=engine) self.session = Session() else: self.session = session diff --git a/tests/python/coverage_run.sh b/tests/python/coverage_run.sh index 18fcb4d..3313031 100755 --- a/tests/python/coverage_run.sh +++ b/tests/python/coverage_run.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh set -e coverage erase -coverage run --source=minigrid,server.py --branch -m unittest ${@-discover tests.python} +coverage run --source=minigrid,server.py --branch -m unittest ${@:-discover tests.python} coverage html coverage report -m diff --git a/tests/python/fixture_minigrids.py b/tests/python/fixture_minigrids.py new file mode 100755 index 0000000..7bd18e7 --- /dev/null +++ b/tests/python/fixture_minigrids.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +"""Minigrids fixture""" +import os +import sys + +sys.path.insert(1, os.path.join(sys.path[0], '../..')) + + +def main(): + from minigrid.options import parse_command_line + parse_command_line(sys.argv) + from minigrid import models + # TODO: create minigrid models + print(models) + + +if __name__ == '__main__': + main() diff --git a/tests/python/test_handlers.py b/tests/python/test_handlers.py index 91eae23..5d9ef4c 100644 --- a/tests/python/test_handlers.py +++ b/tests/python/test_handlers.py @@ -1,5 +1,8 @@ from unittest.mock import patch from urllib.parse import urlparse, parse_qs +import uuid + +from bs4 import BeautifulSoup as inconvenient_soup from tornado.testing import ExpectLog @@ -10,11 +13,148 @@ from server import Application +def BeautifulSoup(page): + return inconvenient_soup(page, 'html.parser') + + class TestIndex(HTTPTest): - def test_get(self): + def setUp(self): + super().setUp() + with self.session.begin_nested(): + self.user = models.User(email='a@a.com') + self.session.add(self.user) + self.minigrids = ( + models.Minigrid(name='a', day_tariff=1, night_tariff=2), + models.Minigrid(name='b', day_tariff=10, night_tariff=20), + ) + self.session.add_all(self.minigrids) + + def test_get_not_logged_in(self): response = self.fetch('/') self.assertResponseCode(response, 200) self.assertNotIn('user', response.headers['Set-Cookie']) + self.assertIn('Log In', response.body.decode()) + self.assertNotIn('Log Out', response.body.decode()) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_get_logged_in(self, get_current_user): + get_current_user.return_value = self.user + response = self.fetch('/') + self.assertResponseCode(response, 200) + self.assertNotIn('Log In', response.body.decode()) + self.assertIn('Log Out', response.body.decode()) + body = BeautifulSoup(response.body) + minigrids = body.ul.findAll('li') + self.assertEqual(len(minigrids), 2) + self.assertEqual( + minigrids[0].a['href'], + '/minigrid/' + self.minigrids[0].minigrid_id, + ) + self.assertEqual(minigrids[0].a.text, self.minigrids[0].name + ' ยป') + + +class TestMinigridView(HTTPTest): + def setUp(self): + super().setUp() + with self.session.begin_nested(): + self.user = models.User(email='a@a.com') + self.session.add(self.user) + self.minigrids = ( + models.Minigrid(name='a', day_tariff=1, night_tariff=2), + models.Minigrid(name='b', day_tariff=10, night_tariff=20), + ) + self.session.add_all(self.minigrids) + + def test_get_not_logged_in(self): + response = self.fetch( + '/minigrid/' + self.minigrids[0].minigrid_id, + follow_redirects=False, + ) + self.assertResponseCode(response, 302) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_get_malformed_id(self, get_current_user): + get_current_user.return_value = self.user + with ExpectLog('tornado.access', '404'): + response = self.fetch('/minigrid/' + 'nope') + self.assertResponseCode(response, 404) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_get_nonexistent_id(self, get_current_user): + get_current_user.return_value = self.user + with ExpectLog('tornado.access', '404'): + response = self.fetch('/minigrid/' + str(uuid.uuid4())) + self.assertResponseCode(response, 404) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_get_success(self, get_current_user): + get_current_user.return_value = self.user + response = self.fetch('/minigrid/' + self.minigrids[0].minigrid_id) + self.assertResponseCode(response, 200) + body = BeautifulSoup(response.body) + self.assertIn('Minigrid Name: a', body.h1) + self.assertIn('Day tariff: 1', body.findAll('p')[2]) + + +class TestUsersView(HTTPTest): + def setUp(self): + super().setUp() + with self.session.begin_nested(): + self.users = ( + models.User(email='a@a.com'), + models.User(email='b@b.com'), + ) + self.session.add_all(self.users) + + def test_get_not_logged_in(self): + response = self.fetch('/users', follow_redirects=False) + self.assertResponseCode(response, 302) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_get_logged_in(self, get_current_user): + get_current_user.return_value = self.users[0] + response = self.fetch('/users') + self.assertResponseCode(response, 200) + body = BeautifulSoup(response.body) + user_ul = body.ul.findAll('li') + self.assertEqual(user_ul[0].a['href'], 'mailto:a@a.com') + self.assertEqual(user_ul[1].a['href'], 'mailto:b@b.com') + + def test_post_not_logged_in(self): + with ExpectLog('tornado.access', '403'): + response = self.fetch('/users', method='POST', body='') + self.assertResponseCode(response, 403) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_post_empty_email(self, get_current_user): + get_current_user.return_value = self.users[0] + response = self.fetch('/users?email=', method='POST', body='') + self.assertIn('Could not create user account', response.body.decode()) + self.assertIn('not a valid e-mail address', response.body.decode()) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_post_invalid_email(self, get_current_user): + get_current_user.return_value = self.users[0] + response = self.fetch('/users?email=notemail', method='POST', body='') + self.assertIn('Could not create user account', response.body.decode()) + self.assertIn('not a valid e-mail address', response.body.decode()) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_post_user_exists(self, get_current_user): + get_current_user.return_value = self.users[0] + response = self.fetch('/users?email=a@a.com', method='POST', body='') + self.assertIn( + 'Account for a@a.com already exists', response.body.decode()) + + @patch('minigrid.handlers.BaseHandler.get_current_user') + def test_post_success(self, get_current_user): + get_current_user.return_value = self.users[0] + response = self.fetch('/users?email=ba@a.com', method='POST', body='') + body = BeautifulSoup(response.body) + user_ul = body.ul.findAll('li') + self.assertEqual(user_ul[1].a['href'], 'mailto:ba@a.com') + self.assertIsNotNone( + self.session.query(models.User).filter_by(email='ba@a.com').one()) class TestXSRF(HTTPTest): @@ -39,7 +179,7 @@ def test_verify_no_xsrf(self): class TestAuthentication(HTTPTest): def create_user(self, email='a@a.com'): - with self.session.begin(): + with self.session.begin_nested(): self.session.add(models.User(email=email)) def test_login_missing_email(self): diff --git a/tests/python/test_models.py b/tests/python/test_models.py index 978f4e8..9df29c2 100644 --- a/tests/python/test_models.py +++ b/tests/python/test_models.py @@ -5,7 +5,7 @@ class TestUser(Test): def test_create(self): - with self.session.begin(): + with self.session.begin_nested(): self.session.add(models.User(email='a@b.com')) user = self.session.query(models.User).one() self.assertEqual(user.email, 'a@b.com') diff --git a/tests/python/test_server.py b/tests/python/test_server.py index e67028b..1902475 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -1,5 +1,7 @@ from unittest.mock import patch +from sqlalchemy.exc import OperationalError + from tests.python.util import Test from server import Application, main @@ -13,15 +15,25 @@ def test_b64dec_incorrect_padding(self, mock_logging): Application(debug=True) self.assertTrue(mock_logging.info.called) + @patch('server.models.Base.metadata.create_all') + @patch('server.sleep') + def test_wait_for_db(self, sleep, create_all): + create_all.side_effect = OperationalError(None, None, None) + with self.assertLogs(level='ERROR'): + self.assertRaises(OperationalError, Application) + self.assertEqual(len(create_all.mock_calls), 2) + self.assertTrue(sleep.called) + class TestServer(Test): @patch('server.AsyncIOMainLoop') + @patch('server.Application') @patch('server.print') - @patch('server.logging') @patch('server.get_event_loop') - def test_main(self, get_event_loop, mock_logging, mock_print, main_loop): - main() + def test_main(self, get_event_loop, mock_print, application, main_loop): + with self.assertLogs(level='INFO'): + main() self.assertTrue(main_loop().install.called) + self.assertTrue(application().listen.called) self.assertTrue(mock_print.called) - self.assertTrue(mock_logging.info.called) self.assertTrue(get_event_loop().run_forever.called) diff --git a/tests/python/util.py b/tests/python/util.py index 32b7337..4ef5066 100644 --- a/tests/python/util.py +++ b/tests/python/util.py @@ -37,11 +37,12 @@ class Dummy: class Test(unittest.TestCase): def setUp(self): + shutil.rmtree(path('../tests/python/tmp'), ignore_errors=True) os.mkdir(path('../tests/python/tmp')) models.Base.metadata.create_all(engine) self.connection = engine.connect() self.transaction = self.connection.begin() - self.session = Session(bind=self.connection, autocommit=True) + self.session = Session(bind=self.connection) super().setUp() def tearDown(self):