# -*- coding: utf-8 -*- from __future__ import unicode_literals try: import unittest2 as unittest except ImportError: import unittest # NOQA import os import json from collections import defaultdict import six from time import sleep from werkzeug.security import generate_password_hash from flask import session from flask_testing import TestCase from ihatemoney.run import create_app, db, load_configuration from ihatemoney import models from ihatemoney import utils # Unset configuration file env var if previously set os.environ.pop('IHATEMONEY_SETTINGS_FILE_PATH', None) __HERE__ = os.path.dirname(os.path.abspath(__file__)) class BaseTestCase(TestCase): SECRET_KEY = "TEST SESSION" def create_app(self): # Pass the test object as a configuration. return create_app(self) def setUp(self): db.create_all() def tearDown(self): # clean after testing db.session.remove() db.drop_all() def login(self, project, password=None, test_client=None): password = password or project return self.client.post('/authenticate', data=dict( id=project, password=password), follow_redirects=True) def post_project(self, name): """Create a fake project""" # create the project self.client.post("/create", data={ 'name': name, 'id': name, 'password': name, 'contact_email': '%s@notmyidea.org' % name }) def create_project(self, name): project = models.Project( id=name, name=six.text_type(name), password=name, contact_email="%s@notmyidea.org" % name) models.db.session.add(project) models.db.session.commit() class IhatemoneyTestCase(BaseTestCase): SQLALCHEMY_DATABASE_URI = "sqlite://" TESTING = True WTF_CSRF_ENABLED = False # Simplifies the tests. class ConfigurationTestCase(BaseTestCase): def test_default_configuration(self): """Test that default settings are loaded when no other configuration file is specified""" self.assertFalse(self.app.config['DEBUG']) self.assertEqual(self.app.config['SQLALCHEMY_DATABASE_URI'], 'sqlite:////tmp/ihatemoney.db') self.assertFalse(self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS']) self.assertEqual(self.app.config['MAIL_DEFAULT_SENDER'], ("Budget manager", "budget@notmyidea.org")) def test_env_var_configuration_file(self): """Test that settings are loaded from the specified configuration file""" os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__, "ihatemoney_envvar.cfg") load_configuration(self.app) self.assertEqual(self.app.config['SECRET_KEY'], 'lalatra') # Test that the specified configuration file is loaded # even if the default configuration file ihatemoney.cfg exists os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__, "ihatemoney_envvar.cfg") self.app.config.root_path = __HERE__ load_configuration(self.app) self.assertEqual(self.app.config['SECRET_KEY'], 'lalatra') os.environ.pop('IHATEMONEY_SETTINGS_FILE_PATH', None) def test_default_configuration_file(self): """Test that settings are loaded from the default configuration file""" self.app.config.root_path = __HERE__ load_configuration(self.app) self.assertEqual(self.app.config['SECRET_KEY'], 'supersecret') class BudgetTestCase(IhatemoneyTestCase): def test_notifications(self): """Test that the notifications are sent, and that email adresses are checked properly. """ # sending a message to one person with self.app.mail.record_messages() as outbox: # create a project self.login("raclette") self.post_project("raclette") self.client.post("/raclette/invite", data={"emails": 'alexis@notmyidea.org'}) self.assertEqual(len(outbox), 2) self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"]) self.assertEqual(outbox[1].recipients, ["alexis@notmyidea.org"]) # sending a message to multiple persons with self.app.mail.record_messages() as outbox: self.client.post("/raclette/invite", data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'}) # only one message is sent to multiple persons self.assertEqual(len(outbox), 1) self.assertEqual(outbox[0].recipients, ["alexis@notmyidea.org", "toto@notmyidea.org"]) # mail address checking with self.app.mail.record_messages() as outbox: response = self.client.post("/raclette/invite", data={"emails": "toto"}) self.assertEqual(len(outbox), 0) # no message sent self.assertIn("The email toto is not valid", response.data.decode('utf-8')) # mixing good and wrong adresses shouldn't send any messages with self.app.mail.record_messages() as outbox: self.client.post("/raclette/invite", data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid # only one message is sent to multiple persons self.assertEqual(len(outbox), 0) def test_password_reminder(self): # test that it is possible to have an email cotaining the password of a # project in case people forget it (and it happens!) self.create_project("raclette") with self.app.mail.record_messages() as outbox: # a nonexisting project should not send an email self.client.post("/password-reminder", data={"id": "unexisting"}) self.assertEqual(len(outbox), 0) # a mail should be sent when a project exists self.client.post("/password-reminder", data={"id": "raclette"}) self.assertEqual(len(outbox), 1) self.assertIn("raclette", outbox[0].body) self.assertIn("raclette@notmyidea.org", outbox[0].recipients) def test_project_creation(self): with self.app.test_client() as c: # add a valid project c.post("/create", data={ 'name': 'The fabulous raclette party', 'id': 'raclette', 'password': 'party', 'contact_email': 'raclette@notmyidea.org' }) # session is updated self.assertEqual(session['raclette'], 'party') # project is created self.assertEqual(len(models.Project.query.all()), 1) # Add a second project with the same id models.Project.query.get('raclette') c.post("/create", data={ 'name': 'Another raclette party', 'id': 'raclette', # already used ! 'password': 'party', 'contact_email': 'raclette@notmyidea.org' }) # no new project added self.assertEqual(len(models.Project.query.all()), 1) def test_project_deletion(self): with self.app.test_client() as c: c.post("/create", data={ 'name': 'raclette party', 'id': 'raclette', 'password': 'party', 'contact_email': 'raclette@notmyidea.org' }) # project added self.assertEqual(len(models.Project.query.all()), 1) c.get('/raclette/delete') # project removed self.assertEqual(len(models.Project.query.all()), 0) def test_membership(self): self.post_project("raclette") self.login("raclette") # adds a member to this project self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.assertEqual(len(models.Project.query.get("raclette").members), 1) # adds him twice result = self.client.post("/raclette/members/add", data={'name': 'alexis'}) # should not accept him self.assertEqual(len(models.Project.query.get("raclette").members), 1) # add fred self.client.post("/raclette/members/add", data={'name': 'fred'}) self.assertEqual(len(models.Project.query.get("raclette").members), 2) # check fred is present in the bills page result = self.client.get("/raclette/") self.assertIn("fred", result.data.decode('utf-8')) # remove fred self.client.post("/raclette/members/%s/delete" % models.Project.query.get("raclette").members[-1].id) # as fred is not bound to any bill, he is removed self.assertEqual(len(models.Project.query.get("raclette").members), 1) # add fred again self.client.post("/raclette/members/add", data={'name': 'fred'}) fred_id = models.Project.query.get("raclette").members[-1].id # bound him to a bill result = self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': fred_id, 'payed_for': [fred_id, ], 'amount': '25', }) # remove fred self.client.post("/raclette/members/%s/delete" % fred_id) # he is still in the database, but is deactivated self.assertEqual(len(models.Project.query.get("raclette").members), 2) self.assertEqual( len(models.Project.query.get("raclette").active_members), 1) # as fred is now deactivated, check that he is not listed when adding # a bill or displaying the balance result = self.client.get("/raclette/") self.assertNotIn(("/raclette/members/%s/delete" % fred_id), result.data.decode('utf-8')) result = self.client.get("/raclette/add") self.assertNotIn("fred", result.data.decode('utf-8')) # adding him again should reactivate him self.client.post("/raclette/members/add", data={'name': 'fred'}) self.assertEqual( len(models.Project.query.get("raclette").active_members), 2) # adding an user with the same name as another user from a different # project should not cause any troubles self.post_project("randomid") self.login("randomid") self.client.post("/randomid/members/add", data={'name': 'fred'}) self.assertEqual( len(models.Project.query.get("randomid").active_members), 1) def test_person_model(self): self.post_project("raclette") self.login("raclette") # adds a member to this project self.client.post("/raclette/members/add", data={'name': 'alexis'}) alexis = models.Project.query.get("raclette").members[-1] # should not have any bills self.assertFalse(alexis.has_bills()) # bound him to a bill self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': alexis.id, 'payed_for': [alexis.id, ], 'amount': '25', }) # should have a bill now alexis = models.Project.query.get("raclette").members[-1] self.assertTrue(alexis.has_bills()) def test_member_delete_method(self): self.post_project("raclette") self.login("raclette") # adds a member to this project self.client.post("/raclette/members/add", data={'name': 'alexis'}) # try to remove the member using GET method response = self.client.get("/raclette/members/1/delete") self.assertEqual(response.status_code, 405) # delete user using POST method self.client.post("/raclette/members/1/delete") self.assertEqual( len(models.Project.query.get("raclette").active_members), 0) # try to delete an user already deleted self.client.post("/raclette/members/1/delete") def test_demo(self): # test that a demo project is created if none is defined self.assertEqual([], models.Project.query.all()) self.client.get("/demo") self.assertTrue(models.Project.query.get("demo") is not None) def test_deactivated_demo(self): self.app.config['ACTIVATE_DEMO_PROJECT'] = False # test redirection to the create project form when demo is deactivated resp = self.client.get("/demo") self.assertIn('', resp.data.decode('utf-8')) def test_authentication(self): # try to authenticate without credentials should redirect # to the authentication page resp = self.client.post("/authenticate") self.assertIn("Authentication", resp.data.decode('utf-8')) # raclette that the login / logout process works self.create_project("raclette") # try to see the project while not being authenticated should redirect # to the authentication page resp = self.client.get("/raclette", follow_redirects=True) self.assertIn("Authentication", resp.data.decode('utf-8')) # try to connect with wrong credentials should not work with self.app.test_client() as c: resp = c.post("/authenticate", data={'id': 'raclette', 'password': 'nope'}) self.assertIn("Authentication", resp.data.decode('utf-8')) self.assertNotIn('raclette', session) # try to connect with the right credentials should work with self.app.test_client() as c: resp = c.post("/authenticate", data={'id': 'raclette', 'password': 'raclette'}) self.assertNotIn("Authentication", resp.data.decode('utf-8')) self.assertIn('raclette', session) self.assertEqual(session['raclette'], 'raclette') # logout should wipe the session out c.get("/exit") self.assertNotIn('raclette', session) # test that whith admin credentials, one can access every project self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") with self.app.test_client() as c: resp = c.post("/admin?goto=%2Fraclette", data={'admin_password': 'pass'}) self.assertNotIn("Authentication", resp.data.decode('utf-8')) self.assertTrue(session['is_admin']) def test_admin_authentication(self): self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") # Disable public project creation so we have an admin endpoint to test self.app.config['ALLOW_PUBLIC_PROJECT_CREATION'] = False # test the redirection to the authentication page when trying to access admin endpoints resp = self.client.get("/create") self.assertIn('', resp.data.decode('utf-8')) # test right password resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'pass'}) self.assertIn('/create', resp.data.decode('utf-8')) # test wrong password resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.assertNotIn('/create', resp.data.decode('utf-8')) # test empty password resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': ''}) self.assertNotIn('/create', resp.data.decode('utf-8')) def test_login_throttler(self): self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass") # Activate admin login throttling by authenticating 4 times with a wrong passsword self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.assertIn('Too many failed login attempts, please retry later.', resp.data.decode('utf-8')) # Change throttling delay import gc for obj in gc.get_objects(): if isinstance(obj, utils.LoginThrottler): obj._delay = 0.005 break # Wait for delay to expire and retry logging in sleep(1) resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'}) self.assertNotIn('Too many failed login attempts, please retry later.', resp.data.decode('utf-8')) def test_manage_bills(self): self.post_project("raclette") # add two persons self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.client.post("/raclette/members/add", data={'name': 'fred'}) members_ids = [m.id for m in models.Project.query.get("raclette").members] # create a bill self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '25', }) models.Project.query.get("raclette") bill = models.Bill.query.one() self.assertEqual(bill.amount, 25) # edit the bill self.client.post("/raclette/edit/%s" % bill.id, data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '10', }) bill = models.Bill.query.one() self.assertEqual(bill.amount, 10, "bill edition") # delete the bill self.client.get("/raclette/delete/%s" % bill.id) self.assertEqual(0, len(models.Bill.query.all()), "bill deletion") # test balance self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '19', }) self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[1], 'payed_for': members_ids[0], 'amount': '20', }) self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[1], 'payed_for': members_ids, 'amount': '17', }) balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([19.0, -19.0])) # Bill with negative amount self.client.post("/raclette/add", data={ 'date': '2011-08-12', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '-25' }) bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0] self.assertEqual(bill.amount, -25) # add a bill with a comma self.client.post("/raclette/add", data={ 'date': '2011-08-01', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '25,02', }) bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0] self.assertEqual(bill.amount, 25.02) def test_weighted_balance(self): self.post_project("raclette") # add two persons self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.client.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) members_ids = [m.id for m in models.Project.query.get("raclette").members] # test balance self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': members_ids[0], 'payed_for': members_ids, 'amount': '10', }) self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'pommes de terre', 'payer': members_ids[1], 'payed_for': members_ids, 'amount': '10', }) balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([6, -6])) def test_weighted_members_list(self): self.post_project("raclette") # add two persons self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.client.post("/raclette/members/add", data={'name': 'tata', 'weight': 1}) resp = self.client.get("/raclette/") self.assertIn('extra-info', resp.data.decode('utf-8')) self.client.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) resp = self.client.get("/raclette/") self.assertNotIn('extra-info', resp.data.decode('utf-8')) def test_rounding(self): self.post_project("raclette") # add members self.client.post("/raclette/members/add", data={'name': 'alexis'}) self.client.post("/raclette/members/add", data={'name': 'fred'}) self.client.post("/raclette/members/add", data={'name': 'tata'}) # create bills self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'fromage à raclette', 'payer': 1, 'payed_for': [1, 2, 3], 'amount': '24.36', }) self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'red wine', 'payer': 2, 'payed_for': [1], 'amount': '19.12', }) self.client.post("/raclette/add", data={ 'date': '2011-08-10', 'what': 'delicatessen', 'payer': 1, 'payed_for': [1, 2], 'amount': '22', }) balance = models.Project.query.get("raclette").balance result = {} result[models.Project.query.get("raclette").members[0].id] = 8.12 result[models.Project.query.get("raclette").members[1].id] = 0.0 result[models.Project.query.get("raclette").members[2].id] = -8.12 # Since we're using floating point to store currency, we can have some # rounding issues that prevent test from working. # However, we should obtain the same values as the theorical ones if we # round to 2 decimals, like in the UI. for key, value in six.iteritems(balance): self.assertEqual(round(value, 2), result[key]) def test_edit_project(self): # A project should be editable self.post_project("raclette") new_data = { 'name': 'Super raclette party!', 'contact_email': 'alexis@notmyidea.org', 'password': 'didoudida' } resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True) self.assertEqual(resp.status_code, 200) project = models.Project.query.get("raclette") for key, value in new_data.items(): self.assertEqual(getattr(project, key), value, key) # Editing a project with a wrong email address should fail new_data['contact_email'] = 'wrong_email' resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True) self.assertIn("Invalid email address", resp.data.decode('utf-8')) def test_dashboard(self): # test that the dashboard is deactivated by default resp = self.client.post( "/admin?goto=%2Fdashboard", data={'admin_password': 'adminpass'}, follow_redirects=True ) self.assertIn('