from collections import defaultdict
from datetime import datetime, timedelta, date
import re
from urllib.parse import unquote, urlparse, urlunparse
from flask import session, url_for
import pytest
from werkzeug.security import check_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.utils import generate_password_hash
from ihatemoney.versioning import LoggingMode
from ihatemoney.web import build_etag
class TestBudget(IhatemoneyTestCase):
def test_notifications(self):
"""Test that the notifications are sent, and that email addresses
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")
resp = self.client.post(
"/raclette/invite",
data={"emails": "zorglub@notmyidea.org"},
follow_redirects=True,
)
# success notification
assert "Your invitations have been sent" in resp.data.decode("utf-8")
assert len(outbox) == 2
assert outbox[0].recipients == ["raclette@notmyidea.org"]
assert outbox[1].recipients == ["zorglub@notmyidea.org"]
# sending a message to multiple participants
with self.app.mail.record_messages() as outbox:
self.client.post(
"/raclette/invite",
data={"emails": "zorglub@notmyidea.org, toto@notmyidea.org"},
)
# only one message is sent to multiple participants
assert len(outbox) == 1
assert outbox[0].recipients == [
"zorglub@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"})
assert len(outbox) == 0 # no message sent
assert (
'The email toto is not valid'
in response.data.decode("utf-8")
)
# mail address checking for escaping
with self.app.mail.record_messages() as outbox:
response = self.client.post(
"/raclette/invite",
data={"emails": ""},
)
assert len(outbox) == 0 # no message sent
assert (
'The email '
"<img src=x onerror=alert(document.domain)>"
" is not valid" in response.data.decode("utf-8")
)
# mixing good and wrong addresses shouldn't send any messages
with self.app.mail.record_messages() as outbox:
self.client.post(
"/raclette/invite", data={"emails": "zorglub@notmyidea.org, zorglub"}
) # not valid
# only one message is sent to multiple participants
assert len(outbox) == 0
def test_invite(self):
"""Test that invitation e-mails are sent properly"""
self.login("raclette")
self.post_project("raclette")
with self.app.mail.record_messages() as outbox:
self.client.post("/raclette/invite", data={"emails": "toto@notmyidea.org"})
assert len(outbox) == 1
url_start = outbox[0].body.find("You can log in using this link: ") + 32
url_end = outbox[0].body.find(".\n", url_start)
url = outbox[0].body[url_start:url_end]
self.client.post("/exit")
# Test that we got a valid token
resp = self.client.get(url, follow_redirects=True)
assert (
'Add the first participant'
in resp.data.decode("utf-8")
)
# Test empty and invalid tokens
self.client.post("/exit")
# Use another project_id
parsed_url = urlparse(url)
resp = self.client.get(
urlunparse(
parsed_url._replace(
path=parsed_url.path.replace("raclette/", "invalid_project/")
)
),
follow_redirects=True,
)
assert "Create a new project" in resp.data.decode("utf-8")
# A token MUST have a point between payload and signature
resp = self.client.get("/raclette/join/token.invalid", follow_redirects=True)
assert "Provided token is invalid" in resp.data.decode("utf-8")
def test_create_should_remember_project(self):
"""Test that creating a project adds it to the "logged in project" list,
as it does for authentication
"""
self.login("raclette")
self.post_project("raclette")
self.post_project("tartiflette")
data = self.client.get("/raclette/").data.decode("utf-8")
assert data.count('href="/tartiflette/"') == 1
def test_multiple_join(self):
"""Test that joining multiple times a project
doesn't add it multiple times in the session"""
self.login("raclette")
self.post_project("raclette")
project = self.get_project("raclette")
invite_link = url_for(
".join_project", project_id="raclette", token=project.generate_token()
)
self.post_project("tartiflette")
self.client.get(invite_link)
data = self.client.get("/tartiflette/").data.decode("utf-8")
# First join is OK
assert 'href="/raclette/"' in data
# Second join shouldn't add a double link
self.client.get(invite_link)
data = self.client.get("/tartiflette/").data.decode("utf-8")
assert data.count('href="/raclette/"') == 1
def test_invalid_invite_link_with_feed_token(self):
"""Test that a 'feed' token is not valid to join a project"""
self.post_project("raclette")
project = self.get_project("raclette")
invite_link = url_for(
".join_project", project_id="raclette", token=project.generate_token("feed")
)
response = self.client.get(invite_link, follow_redirects=True)
assert "Provided token is invalid" in response.data.decode()
def test_invite_code_invalidation(self):
"""Test that invitation link expire after code change"""
self.login("raclette")
self.post_project("raclette")
response = self.client.get("/raclette/invite").data.decode("utf-8")
link = extract_link(response, "give them the following invitation link")
self.client.post("/exit")
response = self.client.get(link)
# Link is valid
assert response.status_code == 302
# Change password to invalidate token
# Other data are required, but useless for the test
response = self.client.post(
"/raclette/edit",
data={
"name": "raclette",
"contact_email": "zorglub@notmyidea.org",
"current_password": "raclette",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
assert response.status_code == 200
assert "alert-danger" not in response.data.decode("utf-8")
self.client.post("/exit")
response = self.client.get(link, follow_redirects=True)
# Link is invalid
assert "Provided token is invalid" in response.data.decode("utf-8")
def test_password_reminder(self):
# test that it is possible to have an email containing 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"})
assert len(outbox) == 0
# a mail should be sent when a project exists
self.client.post("/password-reminder", data={"id": "raclette"})
assert len(outbox) == 1
assert "raclette" in outbox[0].body
assert "raclette@notmyidea.org" in outbox[0].recipients
def test_password_reset(self):
# test that a password can be changed using a link sent by mail
self.create_project("raclette")
# Get password resetting link from mail
with self.app.mail.record_messages() as outbox:
resp = self.client.post(
"/password-reminder", data={"id": "raclette"}, follow_redirects=True
)
# Check that we are redirected to the right page
assert (
"A link to reset your password has been sent to you"
in resp.data.decode("utf-8")
)
# Check that an email was sent
assert len(outbox) == 1
url_start = outbox[0].body.find("You can reset it here: ") + 23
url_end = outbox[0].body.find(".\n", url_start)
url = outbox[0].body[url_start:url_end]
# Test that we got a valid token
resp = self.client.get(url)
assert "Password confirmation" in resp.data.decode("utf-8")
# Test that password can be changed
self.client.post(
url, data={"password": "pass", "password_confirmation": "pass"}
)
resp = self.login("raclette", password="pass")
assert "
' in resp.data.decode("utf-8") assert len(self.get_project("raclette").members) == 1 assert self.get_project("raclette").members[0].weight == 1 def test_rounding(self): self.post_project("raclette") # add participants self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "jeanne"}) 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], "bill_type": "Expense", "amount": "24.36", }, ) self.client.post( "/raclette/add", data={ "date": "2011-08-10", "what": "red wine", "payer": 2, "payed_for": [1], "bill_type": "Expense", "amount": "19.12", }, ) self.client.post( "/raclette/add", data={ "date": "2011-08-10", "what": "delicatessen", "payer": 1, "payed_for": [1, 2], "bill_type": "Expense", "amount": "22", }, ) balance = self.get_project("raclette").balance result = {} result[self.get_project("raclette").members[0].id] = 8.12 result[self.get_project("raclette").members[1].id] = 0.0 result[self.get_project("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 theoretical ones if we # round to 2 decimals, like in the UI. for key, value in balance.items(): assert 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": "zorglub@notmyidea.org", "password": "didoudida", "logging_preference": LoggingMode.ENABLED.value, "default_currency": "USD", } # It should fail if we don't provide the current password resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False) assert "This field is required" in resp.data.decode("utf-8") project = self.get_project("raclette") assert project.name != new_data["name"] assert project.contact_email != new_data["contact_email"] assert project.default_currency != new_data["default_currency"] assert not check_password_hash(project.password, new_data["password"]) # It should fail if we provide the wrong current password new_data["current_password"] = "patates au fromage" resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=False) assert "Invalid private code" in resp.data.decode("utf-8") project = self.get_project("raclette") assert project.name != new_data["name"] assert project.contact_email != new_data["contact_email"] assert project.default_currency != new_data["default_currency"] assert not check_password_hash(project.password, new_data["password"]) # It should work if we give the current private code new_data["current_password"] = "raclette" resp = self.client.post("/raclette/edit", data=new_data) assert resp.status_code == 302 project = self.get_project("raclette") assert project.name == new_data["name"] assert project.contact_email == new_data["contact_email"] assert project.default_currency == new_data["default_currency"] assert check_password_hash(project.password, new_data["password"]) # 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) assert "Invalid email address" in 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, ) assert '