This commit is contained in:
2022-12-30 16:23:27 +00:00
parent 02776e8478
commit 20da343c54
1304 changed files with 870224 additions and 0 deletions

0
app/tests/__init__.py Normal file
View File

View File

681
app/tests/api/test_alias.py Normal file
View File

@ -0,0 +1,681 @@
import arrow
from flask import url_for
# Need to import directly from config to allow modification from the tests
from app import config
from app.db import Session
from app.email_utils import is_reverse_alias
from app.models import User, Alias, Contact, EmailLog, Mailbox
from tests.api.utils import get_new_user_and_api_key
from tests.utils import login, random_domain
def test_get_aliases_error_without_pagination(flask_client):
user, api_key = get_new_user_and_api_key()
r = flask_client.get(
url_for("api.get_aliases"), headers={"Authentication": api_key.code}
)
assert r.status_code == 400
assert r.json["error"]
def test_get_aliases_with_pagination(flask_client):
user, api_key = get_new_user_and_api_key()
# create more aliases than config.PAGE_LIMIT
for _ in range(config.PAGE_LIMIT + 1):
Alias.create_new_random(user)
Session.commit()
# get aliases on the 1st page, should return config.PAGE_LIMIT aliases
r = flask_client.get(
url_for("api.get_aliases", page_id=0), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert len(r.json["aliases"]) == config.PAGE_LIMIT
# assert returned field
for a in r.json["aliases"]:
assert "id" in a
assert "email" in a
assert "creation_date" in a
assert "creation_timestamp" in a
assert "nb_forward" in a
assert "nb_block" in a
assert "nb_reply" in a
assert "enabled" in a
assert "note" in a
# get aliases on the 2nd page, should return 2 aliases
# as the total number of aliases is config.PAGE_LIMIT +2
# 1 alias is created when user is created
r = flask_client.get(
url_for("api.get_aliases", page_id=1), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert len(r.json["aliases"]) == 2
def test_get_aliases_query(flask_client):
user, api_key = get_new_user_and_api_key()
# create more aliases than config.PAGE_LIMIT
Alias.create_new(user, "prefix1")
Alias.create_new(user, "prefix2")
Session.commit()
# get aliases without query, should return 3 aliases as one alias is created when user is created
r = flask_client.get(
url_for("api.get_aliases", page_id=0), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert len(r.json["aliases"]) == 3
# get aliases with "prefix1" query, should return 1 alias
r = flask_client.get(
url_for("api.get_aliases", page_id=0),
headers={"Authentication": api_key.code},
json={"query": "prefix1"},
)
assert r.status_code == 200
assert len(r.json["aliases"]) == 1
def test_get_aliases_v2(flask_client):
user = login(flask_client)
a0 = Alias.create_new(user, "prefix0")
a1 = Alias.create_new(user, "prefix1")
Session.commit()
# << Aliases have no activity >>
r = flask_client.get("/api/v2/aliases?page_id=0")
assert r.status_code == 200
r0 = r.json["aliases"][0]
assert "name" in r0
# make sure a1 is returned before a0
assert r0["email"].startswith("prefix1")
assert "id" in r0["mailbox"]
assert "email" in r0["mailbox"]
assert r0["mailboxes"]
for mailbox in r0["mailboxes"]:
assert "id" in mailbox
assert "email" in mailbox
assert "support_pgp" in r0
assert not r0["support_pgp"]
assert "disable_pgp" in r0
assert not r0["disable_pgp"]
# << Alias has some activities >>
c0 = Contact.create(
user_id=user.id,
alias_id=a0.id,
website_email="c0@example.com",
reply_email="re0@SL",
commit=True,
)
EmailLog.create(
contact_id=c0.id, user_id=user.id, alias_id=c0.alias_id, commit=True
)
# a1 has more recent activity
c1 = Contact.create(
user_id=user.id,
alias_id=a1.id,
website_email="c1@example.com",
reply_email="re1@SL",
commit=True,
)
EmailLog.create(
contact_id=c1.id, user_id=user.id, alias_id=c1.alias_id, commit=True
)
r = flask_client.get("/api/v2/aliases?page_id=0")
assert r.status_code == 200
r0 = r.json["aliases"][0]
assert r0["latest_activity"]["action"] == "forward"
assert "timestamp" in r0["latest_activity"]
assert r0["latest_activity"]["contact"]["email"] == "c1@example.com"
assert "name" in r0["latest_activity"]["contact"]
assert "reverse_alias" in r0["latest_activity"]["contact"]
assert "pinned" in r0
def test_get_pinned_aliases_v2(flask_client):
user = login(flask_client)
a0 = Alias.create_new(user, "prefix0")
a0.pinned = True
Session.commit()
r = flask_client.get("/api/v2/aliases?page_id=0")
assert r.status_code == 200
# the default alias (created when user is created) and a0 are returned
assert len(r.json["aliases"]) == 2
r = flask_client.get("/api/v2/aliases?page_id=0&pinned=true")
assert r.status_code == 200
# only a0 is returned
assert len(r.json["aliases"]) == 1
assert r.json["aliases"][0]["id"] == a0.id
def test_get_disabled_aliases_v2(flask_client):
user = login(flask_client)
a0 = Alias.create_new(user, "prefix0")
a0.enabled = False
Session.commit()
r = flask_client.get("/api/v2/aliases?page_id=0")
assert r.status_code == 200
# the default alias (created when user is created) and a0 are returned
assert len(r.json["aliases"]) == 2
r = flask_client.get("/api/v2/aliases?page_id=0&disabled=true")
assert r.status_code == 200
# only a0 is returned
assert len(r.json["aliases"]) == 1
assert r.json["aliases"][0]["id"] == a0.id
def test_get_enabled_aliases_v2(flask_client):
user = login(flask_client)
a0 = Alias.create_new(user, "prefix0")
a0.enabled = False
Session.commit()
r = flask_client.get("/api/v2/aliases?page_id=0")
assert r.status_code == 200
# the default alias (created when user is created) and a0 are returned
assert len(r.json["aliases"]) == 2
r = flask_client.get("/api/v2/aliases?page_id=0&enabled=true")
assert r.status_code == 200
# only the first alias is returned
assert len(r.json["aliases"]) == 1
assert r.json["aliases"][0]["id"] != a0.id
def test_delete_alias(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.delete(
url_for("api.delete_alias", alias_id=alias.id),
)
assert r.status_code == 200
assert r.json == {"deleted": True}
def test_toggle_alias(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.post(
url_for("api.toggle_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
assert r.json == {"enabled": False}
def test_alias_activities(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
# create some alias log
contact = Contact.create(
website_email="marketing@example.com",
reply_email="reply@a.b",
alias_id=alias.id,
user_id=alias.user_id,
)
Session.commit()
for _ in range(int(config.PAGE_LIMIT / 2)):
EmailLog.create(
contact_id=contact.id,
is_reply=True,
user_id=contact.user_id,
alias_id=contact.alias_id,
)
for _ in range(int(config.PAGE_LIMIT / 2) + 2):
EmailLog.create(
contact_id=contact.id,
blocked=True,
user_id=contact.user_id,
alias_id=contact.alias_id,
)
r = flask_client.get(
url_for("api.get_alias_activities", alias_id=alias.id, page_id=0),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
assert len(r.json["activities"]) == config.PAGE_LIMIT
for ac in r.json["activities"]:
assert ac["from"]
assert ac["to"]
assert ac["timestamp"]
assert ac["action"]
assert ac["reverse_alias"]
assert ac["reverse_alias_address"]
# second page, should return 1 or 2 results only
r = flask_client.get(
url_for("api.get_alias_activities", alias_id=alias.id, page_id=1),
headers={"Authentication": api_key.code},
)
assert len(r.json["activities"]) < 3
def test_update_alias(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"note": "test note"},
)
assert r.status_code == 200
def test_update_alias_mailbox(flask_client):
user, api_key = get_new_user_and_api_key()
mb = Mailbox.create(user_id=user.id, email="ab@cd.com", verified=True)
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_id": mb.id},
)
assert r.status_code == 200
# fail when update with non-existing mailbox
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_id": -1},
)
assert r.status_code == 400
def test_update_alias_name(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"name": "Test Name"},
)
assert r.status_code == 200
alias = Alias.get(alias.id)
assert alias.name == "Test Name"
# update name with linebreak
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"name": "Test \nName"},
)
assert r.status_code == 200
alias = Alias.get(alias.id)
assert alias.name == "Test Name"
def test_update_alias_mailboxes(flask_client):
user, api_key = get_new_user_and_api_key()
mb1 = Mailbox.create(user_id=user.id, email="ab1@cd.com", verified=True)
mb2 = Mailbox.create(user_id=user.id, email="ab2@cd.com", verified=True)
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_ids": [mb1.id, mb2.id]},
)
assert r.status_code == 200
alias = Alias.get(alias.id)
assert alias.mailbox
assert len(alias._mailboxes) == 1
# fail when update with empty mailboxes
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_ids": []},
)
assert r.status_code == 400
def test_update_disable_pgp(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
assert not alias.disable_pgp
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"disable_pgp": True},
)
assert r.status_code == 200
alias = Alias.get(alias.id)
assert alias.disable_pgp
def test_update_pinned(flask_client):
user = login(flask_client)
alias = Alias.filter_by(user_id=user.id).first()
assert not alias.pinned
r = flask_client.patch(
url_for("api.update_alias", alias_id=alias.id),
json={"pinned": True},
)
assert r.status_code == 200
assert alias.pinned
def test_alias_contacts(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
# create some alias log
for i in range(config.PAGE_LIMIT + 1):
contact = Contact.create(
website_email=f"marketing-{i}@example.com",
reply_email=f"reply-{i}@a.b",
alias_id=alias.id,
user_id=alias.user_id,
)
Session.commit()
EmailLog.create(
contact_id=contact.id,
is_reply=True,
user_id=contact.user_id,
alias_id=contact.alias_id,
)
Session.commit()
r = flask_client.get(f"/api/aliases/{alias.id}/contacts?page_id=0")
assert r.status_code == 200
assert len(r.json["contacts"]) == config.PAGE_LIMIT
for ac in r.json["contacts"]:
assert ac["creation_date"]
assert ac["creation_timestamp"]
assert ac["last_email_sent_date"]
assert ac["last_email_sent_timestamp"]
assert ac["contact"]
assert ac["reverse_alias"]
assert ac["reverse_alias_address"]
assert "block_forward" in ac
# second page, should return 1 result only
r = flask_client.get(f"/api/aliases/{alias.id}/contacts?page_id=1")
assert len(r.json["contacts"]) == 1
def test_create_contact_route(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": "First Last <first@example.com>"},
)
assert r.status_code == 201
assert r.json["contact"] == "first@example.com"
assert "creation_date" in r.json
assert "creation_timestamp" in r.json
assert r.json["last_email_sent_date"] is None
assert r.json["last_email_sent_timestamp"] is None
assert r.json["reverse_alias"]
assert r.json["reverse_alias_address"]
assert r.json["existed"] is False
# re-add a contact, should return 200
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": "First2 Last2 <first@example.com>"},
)
assert r.status_code == 200
assert r.json["existed"]
def test_create_contact_route_invalid_alias(flask_client):
user, api_key = get_new_user_and_api_key()
other_user, other_api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(other_user)
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": "First Last <first@example.com>"},
)
assert r.status_code == 403
def test_create_contact_route_free_users(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
# On trial, should be ok
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": f"First Last <first@{random_domain()}>"},
)
assert r.status_code == 201
# End trial but allow via flags for older free users
user.trial_end = arrow.now()
user.flags = 0
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": f"First Last <first@{random_domain()}>"},
)
assert r.status_code == 201
# End trial and disallow for new free users. Config should allow it
user.flags = User.FLAG_FREE_DISABLE_CREATE_ALIAS
Session.commit()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": f"First Last <first@{random_domain()}>"},
)
assert r.status_code == 201
# Set the global config to disable free users from create contacts
config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = True
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"contact": f"First Last <first@{random_domain()}>"},
)
assert r.status_code == 403
config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = False
def test_create_contact_route_empty_contact_address(flask_client):
user = login(flask_client)
alias = Alias.filter_by(user_id=user.id).first()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
json={"contact": ""},
)
assert r.status_code == 400
assert r.json["error"] == "Empty address is not a valid email address"
def test_create_contact_route_invalid_contact_email(flask_client):
user = login(flask_client)
alias = Alias.filter_by(user_id=user.id).first()
r = flask_client.post(
url_for("api.create_contact_route", alias_id=alias.id),
json={"contact": "@gmail.com"},
)
assert r.status_code == 400
assert r.json["error"] == "@gmail.com is not a valid email address"
def test_delete_contact(flask_client):
user, api_key = get_new_user_and_api_key()
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
alias_id=alias.id,
website_email="contact@example.com",
reply_email="reply+random@sl.io",
user_id=alias.user_id,
)
Session.commit()
r = flask_client.delete(
url_for("api.delete_contact", contact_id=contact.id),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
assert r.json == {"deleted": True}
def test_get_alias(flask_client):
user, api_key = get_new_user_and_api_key()
# create more aliases than config.PAGE_LIMIT
alias = Alias.create_new_random(user)
Session.commit()
# get aliases on the 1st page, should return config.PAGE_LIMIT aliases
r = flask_client.get(
url_for("api.get_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
# assert returned field
res = r.json
assert "id" in res
assert "email" in res
assert "creation_date" in res
assert "creation_timestamp" in res
assert "nb_forward" in res
assert "nb_block" in res
assert "nb_reply" in res
assert "enabled" in res
assert "note" in res
assert "pinned" in res
def test_is_reverse_alias(flask_client):
assert is_reverse_alias("ra+abcd@sl.local")
assert is_reverse_alias("reply+abcd@sl.local")
assert not is_reverse_alias("ra+abcd@test.org")
assert not is_reverse_alias("reply+abcd@test.org")
assert not is_reverse_alias("abcd@test.org")
def test_toggle_contact(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
alias_id=alias.id,
website_email="contact@example.com",
reply_email="reply+random@sl.io",
user_id=alias.user_id,
)
Session.commit()
r = flask_client.post(f"/api/contacts/{contact.id}/toggle")
assert r.status_code == 200
assert r.json == {"block_forward": True}
def test_get_aliases_disabled_account(flask_client):
user, api_key = get_new_user_and_api_key()
r = flask_client.get(
"/api/v2/aliases?page_id=0",
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
user.disabled = True
Session.commit()
r = flask_client.get(
"/api/v2/aliases?page_id=0",
headers={"Authentication": api_key.code},
)
assert r.status_code == 403

View File

@ -0,0 +1,127 @@
from flask import url_for
from app.db import Session
from app.models import AliasUsedOn, Alias
from tests.api.utils import get_new_user_and_api_key
from tests.utils import login
def test_different_scenarios_v4(flask_client):
user, api_key = get_new_user_and_api_key()
# <<< without hostname >>>
r = flask_client.get(
"/api/v4/alias/options", headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert r.json["can_create"]
assert r.json["suffixes"]
assert r.json["prefix_suggestion"] == "" # no hostname => no suggestion
# <<< with hostname >>>
r = flask_client.get(
url_for("api.options_v4", hostname="www.test.com"),
headers={"Authentication": api_key.code},
)
assert r.json["prefix_suggestion"] == "test"
# <<< with recommendation >>>
alias = Alias.create_new(user, prefix="test")
Session.commit()
AliasUsedOn.create(
alias_id=alias.id, hostname="www.test.com", user_id=alias.user_id
)
Session.commit()
r = flask_client.get(
url_for("api.options_v4", hostname="www.test.com"),
headers={"Authentication": api_key.code},
)
assert r.json["recommendation"]["alias"] == alias.email
assert r.json["recommendation"]["hostname"] == "www.test.com"
def test_different_scenarios_v4_2(flask_client):
user, api_key = get_new_user_and_api_key()
# <<< without hostname >>>
r = flask_client.get(
url_for("api.options_v4"), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert r.json["can_create"]
assert r.json["suffixes"]
assert r.json["prefix_suggestion"] == "" # no hostname => no suggestion
for (suffix, signed_suffix) in r.json["suffixes"]:
assert signed_suffix.startswith(suffix)
# <<< with hostname >>>
r = flask_client.get(
url_for("api.options_v4", hostname="www.test.com"),
headers={"Authentication": api_key.code},
)
assert r.json["prefix_suggestion"] == "test"
# <<< with recommendation >>>
alias = Alias.create_new(user, prefix="test")
Session.commit()
AliasUsedOn.create(
alias_id=alias.id, hostname="www.test.com", user_id=alias.user_id
)
Session.commit()
r = flask_client.get(
url_for("api.options_v4", hostname="www.test.com"),
headers={"Authentication": api_key.code},
)
assert r.json["recommendation"]["alias"] == alias.email
assert r.json["recommendation"]["hostname"] == "www.test.com"
def test_different_scenarios_v5(flask_client):
user = login(flask_client)
# <<< without hostname >>>
r = flask_client.get("/api/v5/alias/options")
assert r.status_code == 200
assert r.json["can_create"]
assert r.json["suffixes"]
assert r.json["prefix_suggestion"] == "" # no hostname => no suggestion
for suffix_payload in r.json["suffixes"]:
suffix, signed_suffix = (
suffix_payload["suffix"],
suffix_payload["signed_suffix"],
)
assert signed_suffix.startswith(suffix)
assert "is_custom" in suffix_payload
assert "is_premium" in suffix_payload
# <<< with hostname >>>
r = flask_client.get("/api/v5/alias/options?hostname=www.test.com")
assert r.json["prefix_suggestion"] == "test"
# <<< with hostname with 2 parts TLD, for example wwww.numberoneshoes.co.nz >>>
r = flask_client.get("/api/v5/alias/options?hostname=wwww.numberoneshoes.co.nz")
assert r.json["prefix_suggestion"] == "numberoneshoes"
# <<< with recommendation >>>
alias = Alias.create_new(user, prefix="test")
Session.commit()
AliasUsedOn.create(
alias_id=alias.id, hostname="www.test.com", user_id=alias.user_id
)
Session.commit()
r = flask_client.get(url_for("api.options_v4", hostname="www.test.com"))
assert r.json["recommendation"]["alias"] == alias.email
assert r.json["recommendation"]["hostname"] == "www.test.com"

201
app/tests/api/test_apple.py Normal file

File diff suppressed because one or more lines are too long

272
app/tests/api/test_auth.py Normal file
View File

@ -0,0 +1,272 @@
import pytest
import unicodedata
from flask import url_for
from app import config
from app.db import Session
from app.models import User, AccountActivation
from tests.utils import random_email
PASSWORD_1 = "Aurélie"
PASSWORD_2 = unicodedata.normalize("NFKD", PASSWORD_1)
assert PASSWORD_1 != PASSWORD_2
def setup_module():
config.SKIP_MX_LOOKUP_ON_CHECK = True
def teardown_module():
config.SKIP_MX_LOOKUP_ON_CHECK = False
@pytest.mark.parametrize("mfa", (True, False), ids=("MFA", "no MFA"))
def test_auth_login_success(flask_client, mfa: bool):
email = random_email()
User.create(
email=email,
password=PASSWORD_1,
name="Test User",
activated=True,
enable_otp=mfa,
)
Session.commit()
r = flask_client.post(
"/api/auth/login",
json={
"email": email,
"password": PASSWORD_2,
"device": "Test Device",
},
)
assert r.status_code == 200
assert r.json["name"] == "Test User"
assert r.json["email"]
if mfa:
assert r.json["api_key"] is None
assert r.json["mfa_enabled"]
assert r.json["mfa_key"]
else:
assert r.json["api_key"]
assert not r.json["mfa_enabled"]
assert r.json["mfa_key"] is None
def test_auth_login_device_exist(flask_client):
email = random_email()
User.create(email=email, password="password", name="Test User", activated=True)
Session.commit()
r = flask_client.post(
url_for("api.auth_login"),
json={
"email": email,
"password": "password",
"device": "Test Device",
},
)
assert r.status_code == 200
api_key = r.json["api_key"]
assert not r.json["mfa_enabled"]
assert r.json["mfa_key"] is None
assert r.json["name"] == "Test User"
# same device, should return same api_key
r = flask_client.post(
url_for("api.auth_login"),
json={
"email": email,
"password": "password",
"device": "Test Device",
},
)
assert r.json["api_key"] == api_key
def test_auth_register_success(flask_client):
email = random_email()
assert AccountActivation.first() is None
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "password"},
)
assert r.status_code == 200
assert r.json["msg"]
# make sure an activation code is created
act_code = AccountActivation.first()
assert act_code
assert len(act_code.code) == 6
assert act_code.tries == 3
def test_auth_register_too_short_password(flask_client):
email = random_email()
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "short"},
)
assert r.status_code == 400
assert r.json["error"] == "password too short"
def test_auth_register_too_long_password(flask_client):
email = random_email()
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "0123456789" * 11},
)
assert r.status_code == 400
assert r.json["error"] == "password too long"
def test_auth_activate_success(flask_client):
email = random_email()
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "password"},
)
assert r.status_code == 200
assert r.json["msg"]
# get the activation code
act_code = AccountActivation.first()
assert act_code
assert len(act_code.code) == 6
r = flask_client.post(
url_for("api.auth_activate"),
json={"email": email, "code": act_code.code},
)
assert r.status_code == 200
def test_auth_activate_wrong_email(flask_client):
r = flask_client.post(
url_for("api.auth_activate"), json={"email": "abcd@gmail.com", "code": "123456"}
)
assert r.status_code == 400
def test_auth_activate_user_already_activated(flask_client):
email = random_email()
User.create(email=email, password="password", name="Test User", activated=True)
Session.commit()
r = flask_client.post(
url_for("api.auth_activate"), json={"email": email, "code": "123456"}
)
assert r.status_code == 400
def test_auth_activate_wrong_code(flask_client):
email = random_email()
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "password"},
)
assert r.status_code == 200
assert r.json["msg"]
# get the activation code
act_code = AccountActivation.first()
assert act_code
assert len(act_code.code) == 6
assert act_code.tries == 3
# make sure to create a wrong code
wrong_code = act_code.code + "123"
r = flask_client.post(
url_for("api.auth_activate"),
json={"email": email, "code": wrong_code},
)
assert r.status_code == 400
# make sure the nb tries decrements
act_code = AccountActivation.first()
assert act_code.tries == 2
def test_auth_activate_too_many_wrong_code(flask_client):
email = random_email()
r = flask_client.post(
url_for("api.auth_register"),
json={"email": email, "password": "password"},
)
assert r.status_code == 200
assert r.json["msg"]
# get the activation code
act_code = AccountActivation.first()
assert act_code
assert len(act_code.code) == 6
assert act_code.tries == 3
# make sure to create a wrong code
wrong_code = act_code.code + "123"
for _ in range(2):
r = flask_client.post(
url_for("api.auth_activate"),
json={"email": email, "code": wrong_code},
)
assert r.status_code == 400
# the activation code is deleted
r = flask_client.post(
url_for("api.auth_activate"),
json={"email": email, "code": wrong_code},
)
assert r.status_code == 410
# make sure the nb tries decrements
assert AccountActivation.first() is None
def test_auth_reactivate_success(flask_client):
email = random_email()
User.create(email=email, password="password", name="Test User")
Session.commit()
r = flask_client.post(url_for("api.auth_reactivate"), json={"email": email})
assert r.status_code == 200
# make sure an activation code is created
act_code = AccountActivation.first()
assert act_code
assert len(act_code.code) == 6
assert act_code.tries == 3
def test_auth_login_forgot_password(flask_client):
email = random_email()
User.create(email=email, password="password", name="Test User", activated=True)
Session.commit()
r = flask_client.post(
url_for("api.forgot_password"),
json={"email": email},
)
assert r.status_code == 200
# No such email, still return 200
r = flask_client.post(
url_for("api.forgot_password"),
json={"email": random_email()},
)
assert r.status_code == 200

View File

@ -0,0 +1,46 @@
import pyotp
from flask import url_for
from itsdangerous import Signer
from app.config import FLASK_SECRET
from tests.utils import create_new_user
def test_auth_mfa_success(flask_client):
user = create_new_user()
user.enable_otp = True
user.otp_secret = "base32secret3232"
totp = pyotp.TOTP(user.otp_secret)
s = Signer(FLASK_SECRET)
mfa_key = s.sign(str(user.id))
r = flask_client.post(
url_for("api.auth_mfa"),
json={"mfa_token": totp.now(), "mfa_key": mfa_key, "device": "Test Device"},
)
assert r.status_code == 200
assert r.json["api_key"]
assert r.json["email"]
assert r.json["name"] == "Test User"
def test_auth_wrong_mfa_key(flask_client):
user = create_new_user()
user.enable_otp = True
user.otp_secret = "base32secret3232"
totp = pyotp.TOTP(user.otp_secret)
r = flask_client.post(
url_for("api.auth_mfa"),
json={
"mfa_token": totp.now(),
"mfa_key": "wrong mfa key",
"device": "Test Device",
},
)
assert r.status_code == 400
assert r.json["error"]

View File

@ -0,0 +1,116 @@
from app.alias_utils import delete_alias
from app.models import CustomDomain, Alias, Mailbox
from tests.utils import login
def test_get_custom_domains(flask_client):
user = login(flask_client)
CustomDomain.create(user_id=user.id, domain="test1.org", verified=True, commit=True)
CustomDomain.create(
user_id=user.id, domain="test2.org", verified=False, commit=True
)
r = flask_client.get(
"/api/custom_domains",
)
assert r.status_code == 200
assert len(r.json["custom_domains"]) == 2
for domain in r.json["custom_domains"]:
assert domain["domain_name"]
assert domain["id"]
assert domain["nb_alias"] == 0
assert "is_verified" in domain
assert "catch_all" in domain
assert "name" in domain
assert "random_prefix_generation" in domain
assert domain["creation_date"]
assert domain["creation_timestamp"]
assert domain["mailboxes"]
for mailbox in domain["mailboxes"]:
assert "id" in mailbox
assert "email" in mailbox
def test_update_custom_domains(flask_client):
user = login(flask_client)
d1 = CustomDomain.create(
user_id=user.id, domain="test1.org", verified=True, commit=True
)
# test update catch all
assert d1.catch_all is False
r = flask_client.patch(f"/api/custom_domains/{d1.id}", json={"catch_all": True})
assert r.status_code == 200
assert d1.catch_all is True
# make sure the full domain json is returned
cd_json = r.json["custom_domain"]
assert cd_json["domain_name"]
assert cd_json["id"] == d1.id
assert cd_json["nb_alias"] == 0
assert "is_verified" in cd_json
assert "catch_all" in cd_json
assert "name" in cd_json
assert "random_prefix_generation" in cd_json
assert cd_json["creation_date"]
assert cd_json["creation_timestamp"]
assert cd_json["mailboxes"]
for mailbox in cd_json["mailboxes"]:
assert "id" in mailbox
assert "email" in mailbox
# test update random_prefix_generation
assert d1.random_prefix_generation is False
r = flask_client.patch(
f"/api/custom_domains/{d1.id}", json={"random_prefix_generation": True}
)
assert r.status_code == 200
assert d1.random_prefix_generation is True
# test update name
assert d1.name is None
r = flask_client.patch(f"/api/custom_domains/{d1.id}", json={"name": "test name"})
assert r.status_code == 200
assert d1.name == "test name"
# test update mailboxes
assert d1.mailboxes == [user.default_mailbox]
mb = Mailbox.create(
user_id=user.id, email="test@example.org", verified=True, commit=True
)
r = flask_client.patch(
f"/api/custom_domains/{d1.id}", json={"mailbox_ids": [mb.id]}
)
assert r.status_code == 200
assert d1.mailboxes == [mb]
def test_get_custom_domain_trash(flask_client):
user = login(flask_client)
cd = CustomDomain.create(
user_id=user.id, domain="test1.org", verified=True, commit=True
)
alias = Alias.create(
user_id=user.id,
email="first@test1.org",
custom_domain_id=cd.id,
mailbox_id=user.default_mailbox_id,
commit=True,
)
delete_alias(alias, user)
r = flask_client.get(
f"/api/custom_domains/{cd.id}/trash",
)
for deleted_alias in r.json["aliases"]:
assert deleted_alias["alias"]
assert deleted_alias["deletion_timestamp"] > 0

View File

@ -0,0 +1,141 @@
from app.db import Session
from app.import_utils import import_from_csv
from app.models import (
CustomDomain,
Mailbox,
Alias,
BatchImport,
File,
)
from tests.utils_test_alias import alias_export
from tests.utils import login, random_domain, random_token
def test_export(flask_client):
alias_export(flask_client, "api.export_aliases")
def test_import_no_mailboxes_no_domains(flask_client):
# Create user
user = login(flask_client)
# Check start state
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # Onboarding alias
alias_data = [
"alias,note",
"ebay@my-domain.com,Used on eBay",
'facebook@my-domain.com,"Used on Facebook, Instagram."',
]
file = File.create(path=f"/{random_token()}", commit=True)
batch_import = BatchImport.create(user_id=user.id, file_id=file.id, commit=True)
import_from_csv(batch_import, user, alias_data)
# Should have failed to import anything new because my-domain.com isn't registered
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # +0
def test_import_no_mailboxes(flask_client):
# Create user
user = login(flask_client)
# Check start state
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # Onboarding alias
domain = random_domain()
# Create domain
CustomDomain.create(user_id=user.id, domain=domain, ownership_verified=True)
Session.commit()
alias_data = [
"alias,note",
f"ebay@{domain},Used on eBay",
f'facebook@{domain},"Used on Facebook, Instagram."',
]
file = File.create(path=f"/{random_token()}", commit=True)
batch_import = BatchImport.create(user_id=user.id, file_id=file.id)
import_from_csv(batch_import, user, alias_data)
assert len(Alias.filter_by(user_id=user.id).all()) == 3 # +2
def test_import_no_domains(flask_client):
# Create user
user = login(flask_client)
# Check start state
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # Onboarding alias
alias_data = [
"alias,note,mailboxes",
"ebay@my-domain.com,Used on eBay,destination@my-destination-domain.com",
'facebook@my-domain.com,"Used on Facebook, Instagram.",destination1@my-destination-domain.com destination2@my-destination-domain.com',
]
file = File.create(path=f"/{random_token()}", commit=True)
batch_import = BatchImport.create(user_id=user.id, file_id=file.id)
import_from_csv(batch_import, user, alias_data)
# Should have failed to import anything new because my-domain.com isn't registered
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # +0
def test_import(flask_client):
# Create user
user = login(flask_client)
# Check start state
assert len(Alias.filter_by(user_id=user.id).all()) == 1 # Onboarding alias
domain1 = random_domain()
domain2 = random_domain()
# Create domains
CustomDomain.create(user_id=user.id, domain=domain1, ownership_verified=True)
CustomDomain.create(user_id=user.id, domain=domain2, ownership_verified=True)
Session.commit()
# Create mailboxes
mailbox1 = Mailbox.create(
user_id=user.id, email=f"destination@{domain2}", verified=True
)
mailbox2 = Mailbox.create(
user_id=user.id, email=f"destination2@{domain2}", verified=True
)
Session.commit()
alias_data = [
"alias,note,mailboxes",
f"ebay@{domain1},Used on eBay,destination@{domain2}",
f'facebook@{domain1},"Used on Facebook, Instagram.",destination@{domain2} destination2@{domain2}',
]
file = File.create(path=f"/{random_token()}", commit=True)
batch_import = BatchImport.create(user_id=user.id, file_id=file.id)
import_from_csv(batch_import, user, alias_data)
aliases = Alias.filter_by(user_id=user.id).order_by(Alias.id).all()
assert len(aliases) == 3 # +2
# aliases[0] is the onboarding alias, skip it
# eBay alias
assert aliases[1].email == f"ebay@{domain1}"
assert len(aliases[1].mailboxes) == 1
# First one should be primary
assert aliases[1].mailbox_id == mailbox1.id
# Others are sorted
assert aliases[1].mailboxes[0] == mailbox1
# Facebook alias
assert aliases[2].email == f"facebook@{domain1}"
assert len(aliases[2].mailboxes) == 2
# First one should be primary
assert aliases[2].mailbox_id == mailbox1.id
# Others are sorted
assert aliases[2].mailboxes[0] == mailbox2
assert aliases[2].mailboxes[1] == mailbox1

View File

@ -0,0 +1,191 @@
from flask import url_for
from app.db import Session
from app.models import Mailbox
from tests.utils import login
def test_create_mailbox(flask_client):
login(flask_client)
r = flask_client.post(
"/api/mailboxes",
json={"email": "mailbox@gmail.com"},
)
assert r.status_code == 201
assert r.json["email"] == "mailbox@gmail.com"
assert r.json["verified"] is False
assert r.json["id"] > 0
assert r.json["default"] is False
assert r.json["nb_alias"] == 0
# invalid email address
r = flask_client.post(
"/api/mailboxes",
json={"email": "gmail.com"},
)
assert r.status_code == 400
assert r.json == {"error": "gmail.com invalid"}
def test_create_mailbox_fail_for_free_user(flask_client):
user = login(flask_client)
user.trial_end = None
Session.commit()
r = flask_client.post(
"/api/mailboxes",
json={"email": "mailbox@gmail.com"},
)
assert r.status_code == 400
assert r.json == {"error": "Only premium plan can add additional mailbox"}
def test_delete_mailbox(flask_client):
user = login(flask_client)
# create a mailbox
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com")
Session.commit()
r = flask_client.delete(
f"/api/mailboxes/{mb.id}",
)
assert r.status_code == 200
def test_delete_default_mailbox(flask_client):
user = login(flask_client)
# assert user cannot delete the default mailbox
r = flask_client.delete(
url_for("api.delete_mailbox", mailbox_id=user.default_mailbox_id),
)
assert r.status_code == 400
def test_set_mailbox_as_default(flask_client):
user = login(flask_client)
mb = Mailbox.create(
user_id=user.id, email="mb@gmail.com", verified=True, commit=True
)
assert user.default_mailbox_id != mb.id
r = flask_client.put(
f"/api/mailboxes/{mb.id}",
json={"default": True},
)
assert r.status_code == 200
assert user.default_mailbox_id == mb.id
# <<< Cannot set an unverified mailbox as default >>>
mb.verified = False
Session.commit()
r = flask_client.put(
f"/api/mailboxes/{mb.id}",
json={"default": True},
)
assert r.status_code == 400
assert r.json == {"error": "Unverified mailbox cannot be used as default mailbox"}
def test_update_mailbox_email(flask_client):
user = login(flask_client)
# create a mailbox
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com")
Session.commit()
r = flask_client.put(
f"/api/mailboxes/{mb.id}",
json={"email": "new-email@gmail.com"},
)
assert r.status_code == 200
mb = Mailbox.get(mb.id)
assert mb.new_email == "new-email@gmail.com"
def test_cancel_mailbox_email_change(flask_client):
user = login(flask_client)
# create a mailbox
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com")
Session.commit()
# update mailbox email
r = flask_client.put(
f"/api/mailboxes/{mb.id}",
json={"email": "new-email@gmail.com"},
)
assert r.status_code == 200
mb = Mailbox.get(mb.id)
assert mb.new_email == "new-email@gmail.com"
# cancel mailbox email change
r = flask_client.put(
url_for("api.delete_mailbox", mailbox_id=mb.id),
json={"cancel_email_change": True},
)
assert r.status_code == 200
mb = Mailbox.get(mb.id)
assert mb.new_email is None
def test_get_mailboxes(flask_client):
user = login(flask_client)
Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
Mailbox.create(user_id=user.id, email="m2@example.com", verified=False)
Session.commit()
r = flask_client.get(
"/api/mailboxes",
)
assert r.status_code == 200
# m2@example.com is not returned as it's not verified
assert len(r.json["mailboxes"]) == 2
for mb in r.json["mailboxes"]:
assert "email" in mb
assert "id" in mb
assert "default" in mb
assert "creation_timestamp" in mb
assert "nb_alias" in mb
assert "verified" in mb
def test_get_mailboxes_v2(flask_client):
user = login(flask_client)
Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
Mailbox.create(user_id=user.id, email="m2@example.com", verified=False)
Session.commit()
r = flask_client.get(
"/api/v2/mailboxes",
)
assert r.status_code == 200
# 3 mailboxes: the default, m1 and m2
assert len(r.json["mailboxes"]) == 3
for mb in r.json["mailboxes"]:
assert "email" in mb
assert "id" in mb
assert "default" in mb
assert "creation_timestamp" in mb
assert "nb_alias" in mb
assert "verified" in mb

View File

@ -0,0 +1,305 @@
from flask import g
from app import config
from app.alias_suffix import signer
from app.alias_utils import delete_alias
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
from app.db import Session
from app.models import Alias, CustomDomain, Mailbox, AliasUsedOn
from app.utils import random_word
from tests.utils import login, random_domain, random_token
def test_v2(flask_client):
login(flask_client)
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
r = flask_client.post(
"/api/v2/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
},
)
assert r.status_code == 201
assert r.json["alias"] == f"prefix.{word}@{EMAIL_DOMAIN}"
res = r.json
assert "id" in res
assert "email" in res
assert "creation_date" in res
assert "creation_timestamp" in res
assert "nb_forward" in res
assert "nb_block" in res
assert "nb_reply" in res
assert "enabled" in res
new_alias: Alias = Alias.get_by(email=r.json["alias"])
assert len(new_alias.mailboxes) == 1
def test_minimal_payload(flask_client):
user = login(flask_client)
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
assert r.status_code == 201
assert r.json["alias"] == f"prefix.{word}@{EMAIL_DOMAIN}"
res = r.json
assert "id" in res
assert "email" in res
assert "creation_date" in res
assert "creation_timestamp" in res
assert "nb_forward" in res
assert "nb_block" in res
assert "nb_reply" in res
assert "enabled" in res
new_alias: Alias = Alias.get_by(email=r.json["alias"])
assert len(new_alias.mailboxes) == 1
def test_full_payload(flask_client):
"""Create alias with:
- additional mailbox
- note
- name
- hostname (in URL)
"""
user = login(flask_client)
# create another mailbox
mb = Mailbox.create(user_id=user.id, email="abcd@gmail.com", verified=True)
Session.commit()
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
prefix = random_token()
assert AliasUsedOn.filter(AliasUsedOn.user_id == user.id).count() == 0
r = flask_client.post(
"/api/v3/alias/custom/new?hostname=example.com",
json={
"alias_prefix": prefix,
"signed_suffix": signed_suffix,
"note": "test note",
"mailbox_ids": [user.default_mailbox_id, mb.id],
"name": "your name",
},
)
assert r.status_code == 201
assert r.json["alias"] == f"{prefix}.{word}@{EMAIL_DOMAIN}"
# assert returned field
res = r.json
assert res["note"] == "test note"
assert res["name"] == "your name"
new_alias: Alias = Alias.get_by(email=r.json["alias"])
assert new_alias.note == "test note"
assert len(new_alias.mailboxes) == 2
alias_used_on = AliasUsedOn.filter(AliasUsedOn.user_id == user.id).first()
assert alias_used_on.alias_id == new_alias.id
assert alias_used_on.hostname == "example.com"
def test_custom_domain_alias(flask_client):
user = login(flask_client)
# create a custom domain
domain = random_domain()
CustomDomain.create(
user_id=user.id, domain=domain, ownership_verified=True, commit=True
)
signed_suffix = signer.sign(f"@{domain}").decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
assert r.status_code == 201
assert r.json["alias"] == f"prefix@{domain}"
def test_wrongly_formatted_payload(flask_client):
login(flask_client)
r = flask_client.post(
"/api/v3/alias/custom/new",
json="string isn't a dict",
)
assert r.status_code == 400
assert r.json == {"error": "request body does not follow the required format"}
def test_mailbox_ids_is_not_an_array(flask_client):
login(flask_client)
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"mailbox_ids": "not an array",
},
)
assert r.status_code == 400
assert r.json == {"error": "mailbox_ids must be an array of id"}
def test_out_of_quota(flask_client):
user = login(flask_client)
user.trial_end = None
Session.commit()
# create MAX_NB_EMAIL_FREE_PLAN custom alias to run out of quota
for _ in range(MAX_NB_EMAIL_FREE_PLAN):
Alias.create_new(user, prefix="test")
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"note": "test note",
"mailbox_ids": [user.default_mailbox_id],
"name": "your name",
},
)
assert r.status_code == 400
assert r.json == {
"error": "You have reached the limitation of a "
"free account with the maximum of 3 aliases, please upgrade your plan to create more aliases"
}
def test_cannot_create_alias_in_trash(flask_client):
user = login(flask_client)
# create a custom domain
domain = random_domain()
CustomDomain.create(
user_id=user.id, domain=domain, ownership_verified=True, commit=True
)
signed_suffix = signer.sign(f"@{domain}").decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
assert r.status_code == 201
assert r.json["alias"] == f"prefix@{domain}"
# delete alias: it's going to be moved to domain trash
alias = Alias.get_by(email=f"prefix@{domain}")
assert alias.custom_domain_id
delete_alias(alias, user)
# try to create the same alias, will fail as the alias is in trash
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix",
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
assert r.status_code == 409
def test_too_many_requests(flask_client):
config.DISABLE_RATE_LIMIT = False
user = login(flask_client)
# create a custom domain
domain = random_domain()
CustomDomain.create(user_id=user.id, domain=domain, verified=True, commit=True)
# can't create more than 5 aliases in 1 minute
for i in range(7):
signed_suffix = signer.sign(f"@{domain}").decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": f"prefix{i}",
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
# to make flask-limiter work with unit test
# https://github.com/alisaifee/flask-limiter/issues/147#issuecomment-642683820
g._rate_limiting_complete = False
else:
# last request
assert r.status_code == 429
assert r.json == {"error": "Rate limit exceeded"}
def test_invalid_alias_2_consecutive_dots(flask_client):
user = login(flask_client)
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
signed_suffix = signer.sign(suffix).decode()
r = flask_client.post(
"/api/v3/alias/custom/new",
json={
"alias_prefix": "prefix.", # with the trailing dot, the alias will have 2 consecutive dots
"signed_suffix": signed_suffix,
"mailbox_ids": [user.default_mailbox_id],
},
)
assert r.status_code == 400
assert r.json == {
"error": "2 consecutive dot signs aren't allowed in an email address"
}

View File

@ -0,0 +1,148 @@
import uuid
from flask import url_for, g
from app import config
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
from app.db import Session
from app.models import Alias, CustomDomain, AliasUsedOn
from tests.utils import login, random_domain
def test_with_hostname(flask_client):
login(flask_client)
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com"),
)
assert r.status_code == 201
assert r.json["alias"].endswith("d1.test")
# make sure alias starts with the suggested prefix
assert r.json["alias"].startswith("test")
# assert returned field
res = r.json
assert "id" in res
assert "email" in res
assert "creation_date" in res
assert "creation_timestamp" in res
assert "nb_forward" in res
assert "nb_block" in res
assert "nb_reply" in res
assert "enabled" in res
assert "note" in res
alias_used_on: AliasUsedOn = AliasUsedOn.order_by(AliasUsedOn.id.desc()).first()
assert alias_used_on.hostname == "www.test.com"
assert alias_used_on.alias_id == res["id"]
def test_with_custom_domain(flask_client):
user = login(flask_client)
domain = random_domain()
CustomDomain.create(
user_id=user.id, domain=domain, ownership_verified=True, commit=True
)
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com"),
)
assert r.status_code == 201
assert r.json["alias"] == f"test@{domain}"
assert Alias.filter_by(user_id=user.id).count() == 2
# call the endpoint again, should return the same alias
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com"),
)
assert r.status_code == 201
assert r.json["alias"] == f"test@{domain}"
# no new alias is created
assert Alias.filter_by(user_id=user.id).count() == 2
def test_without_hostname(flask_client):
login(flask_client)
r = flask_client.post(
url_for("api.new_random_alias"),
)
assert r.status_code == 201
assert r.json["alias"].endswith(EMAIL_DOMAIN)
def test_custom_mode(flask_client):
login(flask_client)
# without note
r = flask_client.post(
url_for("api.new_random_alias", mode="uuid"),
)
assert r.status_code == 201
# extract the uuid part
alias = r.json["alias"]
uuid_part = alias[: len(alias) - len(EMAIL_DOMAIN) - 1]
assert is_valid_uuid(uuid_part)
# with note
r = flask_client.post(
url_for("api.new_random_alias", mode="uuid"),
json={"note": "test note"},
)
assert r.status_code == 201
alias = r.json["alias"]
ge = Alias.get_by(email=alias)
assert ge.note == "test note"
def test_out_of_quota(flask_client):
user = login(flask_client)
user.trial_end = None
Session.commit()
# create MAX_NB_EMAIL_FREE_PLAN random alias to run out of quota
for _ in range(MAX_NB_EMAIL_FREE_PLAN):
Alias.create_new(user, prefix="test1")
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com"),
)
assert r.status_code == 400
assert (
r.json["error"] == "You have reached the limitation of a free account with "
"the maximum of 3 aliases, please upgrade your plan to create more aliases"
)
def test_too_many_requests(flask_client):
config.DISABLE_RATE_LIMIT = False
login(flask_client)
# can't create more than 5 aliases in 1 minute
for _ in range(7):
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com", mode="uuid"),
)
# to make flask-limiter work with unit test
# https://github.com/alisaifee/flask-limiter/issues/147#issuecomment-642683820
g._rate_limiting_complete = False
else:
# last request
assert r.status_code == 429
assert r.json == {"error": "Rate limit exceeded"}
def is_valid_uuid(val):
try:
uuid.UUID(str(val))
return True
except ValueError:
return False

View File

@ -0,0 +1,53 @@
from flask import url_for
from app.db import Session
from app.models import Notification
from tests.api.utils import get_new_user_and_api_key
def test_get_notifications(flask_client):
user, api_key = get_new_user_and_api_key()
# create some notifications
Notification.create(user_id=user.id, message="Test message 1")
Notification.create(user_id=user.id, message="Test message 2")
Session.commit()
r = flask_client.get(
"/api/notifications?page=0",
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
assert r.json["more"] is False
assert len(r.json["notifications"]) == 2
for n in r.json["notifications"]:
assert n["id"] > 0
assert n["message"]
assert "title" in n
assert n["read"] is False
assert n["created_at"]
# no more post at the next page
r = flask_client.get(
url_for("api.get_notifications", page=1),
headers={"Authentication": api_key.code},
)
assert r.json["more"] is False
assert len(r.json["notifications"]) == 0
def test_mark_notification_as_read(flask_client):
user, api_key = get_new_user_and_api_key()
Notification.create(id=1, user_id=user.id, message="Test message 1")
Session.commit()
r = flask_client.post(
url_for("api.mark_as_read", notification_id=1),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
notification = Notification.first()
assert notification.read

View File

@ -0,0 +1,64 @@
import arrow
from app.models import (
PhoneReservation,
PhoneNumber,
PhoneCountry,
PhoneMessage,
)
from tests.utils import login
def test_phone_messages(flask_client):
user = login(flask_client)
country = PhoneCountry.create(name="FR", commit=True)
number = PhoneNumber.create(
country_id=country.id, number="+331234567890", active=True, commit=True
)
reservation = PhoneReservation.create(
number_id=number.id,
user_id=user.id,
start=arrow.now().shift(hours=-1),
end=arrow.now().shift(hours=1),
commit=True,
)
# no messages yet
r = flask_client.post(f"/api/phone/reservations/{reservation.id}")
assert r.status_code == 200
assert r.json == {"ended": False, "messages": []}
# a message arrives
PhoneMessage.create(
number_id=number.id, from_number="from_number", body="body", commit=True
)
r = flask_client.post(f"/api/phone/reservations/{reservation.id}")
assert len(r.json["messages"]) == 1
msg = r.json["messages"][0]
assert msg["body"] == "body"
assert msg["from_number"] == "from_number"
assert "created_at" in msg
assert "id" in msg
# print(json.dumps(r.json, indent=2))
def test_phone_messages_ended_reservation(flask_client):
user = login(flask_client)
country = PhoneCountry.create(name="FR", commit=True)
number = PhoneNumber.create(
country_id=country.id, number="+331234567890", active=True, commit=True
)
reservation = PhoneReservation.create(
number_id=number.id,
user_id=user.id,
start=arrow.now().shift(hours=-2),
end=arrow.now().shift(hours=-1), # reservation is ended
commit=True,
)
r = flask_client.post(f"/api/phone/reservations/{reservation.id}")
assert r.status_code == 200
assert r.json == {"ended": True, "messages": []}

View File

@ -0,0 +1,157 @@
from app.api.serializer import get_alias_infos_with_pagination_v3
from app.config import PAGE_LIMIT
from app.db import Session
from app.models import Alias, Mailbox, Contact
from tests.utils import create_new_user
def test_get_alias_infos_with_pagination_v3(flask_client):
user = create_new_user()
# user has 1 alias that's automatically created when the account is created
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == 1
alias_info = alias_infos[0]
alias = Alias.filter_by(user_id=user.id).first()
assert alias_info.alias == alias
assert alias_info.mailbox == user.default_mailbox
assert alias_info.mailboxes == [user.default_mailbox]
assert alias_info.nb_forward == 0
assert alias_info.nb_blocked == 0
assert alias_info.nb_reply == 0
assert alias_info.latest_email_log is None
assert alias_info.latest_contact is None
def test_get_alias_infos_with_pagination_v3_query_alias_email(flask_client):
"""test the query on the alias email"""
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
alias_infos = get_alias_infos_with_pagination_v3(user, query=alias.email)
assert len(alias_infos) == 1
alias_infos = get_alias_infos_with_pagination_v3(user, query="no match")
assert len(alias_infos) == 0
def test_get_alias_infos_with_pagination_v3_query_alias_mailbox(flask_client):
"""test the query on the alias mailbox email"""
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
alias_infos = get_alias_infos_with_pagination_v3(user, mailbox_id=alias.mailbox_id)
assert len(alias_infos) == 1
def test_get_alias_infos_with_pagination_v3_query_alias_mailboxes(flask_client):
"""test the query on the alias additional mailboxes"""
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com")
alias._mailboxes.append(mb)
Session.commit()
alias_infos = get_alias_infos_with_pagination_v3(user, mailbox_id=mb.id)
assert len(alias_infos) == 1
alias_infos = get_alias_infos_with_pagination_v3(user, query=alias.email)
assert len(alias_infos) == 1
def test_get_alias_infos_with_pagination_v3_query_alias_note(flask_client):
"""test the query on the alias note"""
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
alias.note = "test note"
Session.commit()
alias_infos = get_alias_infos_with_pagination_v3(user, query="test note")
assert len(alias_infos) == 1
def test_get_alias_infos_with_pagination_v3_query_alias_name(flask_client):
"""test the query on the alias name"""
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
alias.name = "Test Name"
Session.commit()
alias_infos = get_alias_infos_with_pagination_v3(user, query="test name")
assert len(alias_infos) == 1
def test_get_alias_infos_with_pagination_v3_no_duplicate(flask_client):
"""When an alias belongs to multiple mailboxes, make sure get_alias_infos_with_pagination_v3
returns no duplicates
"""
user = create_new_user()
alias = Alias.first()
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com")
alias._mailboxes.append(mb)
Session.commit()
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == 1
def test_get_alias_infos_with_pagination_v3_no_duplicate_when_empty_contact(
flask_client,
):
"""
Make sure an alias is returned once when it has 2 contacts that have no email log activity
"""
user = create_new_user()
alias = Alias.first()
Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
)
Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact2@example.com",
reply_email="rep2@sl.local",
)
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == 1
def test_get_alias_infos_pinned_alias(flask_client):
"""Different scenarios with pinned alias"""
user = create_new_user()
# to have 3 pages: 2*PAGE_LIMIT + the alias automatically created for a new account
for _ in range(2 * PAGE_LIMIT):
Alias.create_new_random(user)
first_alias = Alias.filter_by(user_id=user.id).order_by(Alias.id).first()
# should return PAGE_LIMIT alias
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == PAGE_LIMIT
# make sure first_alias is not returned as the default order is alias creation date
assert first_alias not in [ai.alias for ai in alias_infos]
# pin the first alias
first_alias.pinned = True
Session.commit()
alias_infos = get_alias_infos_with_pagination_v3(user)
# now first_alias is the first result
assert first_alias == alias_infos[0].alias
# and the page size is still the same
assert len(alias_infos) == PAGE_LIMIT
# pinned alias isn't included in the search
alias_infos = get_alias_infos_with_pagination_v3(user, query="no match")
assert len(alias_infos) == 0

View File

@ -0,0 +1,105 @@
from app.models import (
CustomDomain,
AliasGeneratorEnum,
SenderFormatEnum,
AliasSuffixEnum,
)
from tests.utils import login, random_domain
def test_get_setting(flask_client):
login(flask_client)
r = flask_client.get("/api/setting")
assert r.status_code == 200
assert r.json == {
"alias_generator": "word",
"notification": True,
"random_alias_default_domain": "sl.local",
"sender_format": "AT",
"random_alias_suffix": "random_string",
}
def test_update_settings_notification(flask_client):
user = login(flask_client)
assert user.notification
r = flask_client.patch("/api/setting", json={"notification": False})
assert r.status_code == 200
assert not user.notification
def test_update_settings_alias_generator(flask_client):
user = login(flask_client)
assert user.alias_generator == AliasGeneratorEnum.word.value
r = flask_client.patch("/api/setting", json={"alias_generator": "invalid"})
assert r.status_code == 400
r = flask_client.patch("/api/setting", json={"alias_generator": "uuid"})
assert r.status_code == 200
assert user.alias_generator == AliasGeneratorEnum.uuid.value
def test_update_settings_random_alias_default_domain(flask_client):
user = login(flask_client)
assert user.default_random_alias_domain() == "sl.local"
r = flask_client.patch(
"/api/setting", json={"random_alias_default_domain": "invalid"}
)
assert r.status_code == 400
r = flask_client.patch(
"/api/setting", json={"random_alias_default_domain": "d1.test"}
)
assert r.status_code == 200
assert user.default_random_alias_domain() == "d1.test"
def test_update_settings_sender_format(flask_client):
user = login(flask_client)
assert user.sender_format == SenderFormatEnum.AT.value
r = flask_client.patch("/api/setting", json={"sender_format": "invalid"})
assert r.status_code == 400
r = flask_client.patch("/api/setting", json={"sender_format": "A"})
assert r.status_code == 200
assert user.sender_format == SenderFormatEnum.A.value
r = flask_client.patch("/api/setting", json={"sender_format": "NAME_ONLY"})
assert r.status_code == 200
assert user.sender_format == SenderFormatEnum.NAME_ONLY.value
def test_get_setting_domains(flask_client):
user = login(flask_client)
domain = random_domain()
CustomDomain.create(user_id=user.id, domain=domain, verified=True, commit=True)
r = flask_client.get("/api/setting/domains")
assert r.status_code == 200
def test_get_setting_domains_v2(flask_client):
user = login(flask_client)
domain = random_domain()
CustomDomain.create(user_id=user.id, domain=domain, verified=True, commit=True)
r = flask_client.get("/api/v2/setting/domains")
assert r.status_code == 200
def test_update_settings_random_alias_suffix(flask_client):
user = login(flask_client)
# default random_alias_suffix is random_string
assert user.random_alias_suffix == AliasSuffixEnum.random_string.value
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "invalid"})
assert r.status_code == 400
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "word"})
assert r.status_code == 200
assert user.random_alias_suffix == AliasSuffixEnum.word.value

View File

@ -0,0 +1,34 @@
from random import random
from flask import url_for
from app.api.base import check_sudo_mode_is_active
from app.db import Session
from app.models import ApiKey
from tests.api.utils import get_new_user_and_api_key
def test_enter_sudo_mode(flask_client):
user, api_key = get_new_user_and_api_key()
password = f"passwd-{random()}"
user.set_password(password)
Session.commit()
r = flask_client.patch(
url_for("api.enter_sudo"),
headers={"Authentication": api_key.code},
json={"password": "invalid"},
)
assert r.status_code == 403
assert not check_sudo_mode_is_active(ApiKey.get(id=api_key.id))
r = flask_client.patch(
url_for("api.enter_sudo"),
headers={"Authentication": api_key.code},
json={"password": password},
)
assert r.status_code == 200
assert r.json == {"ok": True}
assert check_sudo_mode_is_active(ApiKey.get(id=api_key.id))

View File

@ -0,0 +1,68 @@
from random import random
from flask import url_for
from app import config
from app.db import Session
from app.models import Job, ApiToCookieToken
from tests.api.utils import get_new_user_and_api_key
def test_delete_without_sudo(flask_client):
user, api_key = get_new_user_and_api_key()
for job in Job.all():
job.delete(job.id)
Session.commit()
r = flask_client.delete(
url_for("api.delete_user"),
headers={"Authentication": api_key.code},
)
assert r.status_code == 440
assert Job.count() == 0
def test_delete_with_sudo(flask_client):
user, api_key = get_new_user_and_api_key()
password = f"passwd-{random()}"
user.set_password(password)
for job in Job.all():
job.delete(job.id)
Session.commit()
r = flask_client.patch(
url_for("api.enter_sudo"),
headers={"Authentication": api_key.code},
json={"password": password},
)
assert r.status_code == 200
r = flask_client.delete(
url_for("api.delete_user"),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
jobs = Job.all()
assert len(jobs) == 1
job = jobs[0]
assert job.name == config.JOB_DELETE_ACCOUNT
assert job.payload == {"user_id": user.id}
def test_get_cookie_token(flask_client):
user, api_key = get_new_user_and_api_key()
r = flask_client.get(
url_for("api.get_api_session_token"),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
code = r.json["token"]
token = ApiToCookieToken.get_by(code=code)
assert token is not None
assert token.user_id == user.id

View File

@ -0,0 +1,131 @@
from flask import url_for
from app import config
from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from tests.api.utils import get_new_user_and_api_key
from tests.utils import login, random_token, random_email
def test_user_in_trial(flask_client):
user, api_key = get_new_user_and_api_key()
r = flask_client.get(
url_for("api.user_info"), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert r.json == {
"is_premium": True,
"name": "Test User",
"email": user.email,
"in_trial": True,
"profile_picture_url": None,
"max_alias_free_plan": config.MAX_NB_EMAIL_FREE_PLAN,
"connected_proton_address": None,
}
def test_user_linked_to_proton(flask_client):
config.CONNECT_WITH_PROTON = True
user, api_key = get_new_user_and_api_key()
partner = get_proton_partner()
partner_email = random_email()
PartnerUser.create(
user_id=user.id,
partner_id=partner.id,
external_user_id=random_token(),
partner_email=partner_email,
commit=True,
)
r = flask_client.get(
url_for("api.user_info"), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert r.json == {
"is_premium": True,
"name": "Test User",
"email": user.email,
"in_trial": True,
"profile_picture_url": None,
"max_alias_free_plan": config.MAX_NB_EMAIL_FREE_PLAN,
"connected_proton_address": partner_email,
}
def test_wrong_api_key(flask_client):
r = flask_client.get(
url_for("api.user_info"), headers={"Authentication": "Invalid code"}
)
assert r.status_code == 401
assert r.json == {"error": "Wrong api key"}
def test_create_api_key(flask_client):
login(flask_client)
# create api key
r = flask_client.post(url_for("api.create_api_key"), json={"device": "Test device"})
assert r.status_code == 201
assert r.json["api_key"]
def test_logout(flask_client):
login(flask_client)
# logout
r = flask_client.get(
url_for("auth.logout"),
follow_redirects=True,
)
assert r.status_code == 200
def test_change_profile_picture(flask_client):
user = login(flask_client)
assert not user.profile_picture_id
# <<< Set the profile picture >>>
img_base64 = """iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="""
r = flask_client.patch(
"/api/user_info",
json={"profile_picture": img_base64},
)
assert r.status_code == 200
assert r.json["profile_picture_url"] is not None
user = User.get(user.id)
assert user.profile_picture_id
# <<< remove the profile picture >>>
r = flask_client.patch(
"/api/user_info",
json={"profile_picture": None},
)
assert r.status_code == 200
assert r.json["profile_picture_url"] is None
user = User.get(user.id)
assert not user.profile_picture_id
def test_change_name(flask_client):
user = login(flask_client)
assert user.name != "new name"
r = flask_client.patch(
"/api/user_info",
json={"name": "new name"},
)
assert r.status_code == 200
assert r.json["name"] == "new name"
assert user.name == "new name"

13
app/tests/api/utils.py Normal file
View File

@ -0,0 +1,13 @@
from typing import Tuple
from app.models import User, ApiKey
from tests.utils import create_new_user
def get_new_user_and_api_key() -> Tuple[User, ApiKey]:
user = create_new_user()
# create api_key
api_key = ApiKey.create(user.id, "for test", commit=True)
return user, api_key

View File

View File

@ -0,0 +1,29 @@
from flask import url_for
from app.models import ApiToCookieToken, ApiKey
from tests.utils import create_new_user
def test_get_cookie(flask_client):
user = create_new_user()
api_key = ApiKey.create(
user_id=user.id,
commit=True,
)
token = ApiToCookieToken.create(
user_id=user.id,
api_key_id=api_key.id,
commit=True,
)
token_code = token.code
token_id = token.id
r = flask_client.get(
url_for(
"auth.api_to_cookie", token=token_code, next=url_for("dashboard.setting")
),
follow_redirects=True,
)
assert ApiToCookieToken.get(token_id) is None
assert r.headers.getlist("Set-Cookie") is not None

View File

@ -0,0 +1,33 @@
from flask import url_for
from app.db import Session
from app.models import EmailChange, User, ResetPasswordCode
from tests.utils import create_new_user, random_token, random_email
def test_change_email(flask_client):
user = create_new_user()
user.activated = False
user_id = user.id
email_change = EmailChange.create(
user_id=user.id,
code=random_token(),
new_email=random_email(),
)
reset_id = ResetPasswordCode.create(user_id=user_id, code=random_token()).id
email_change_id = email_change.id
email_change_code = email_change.code
new_email = email_change.new_email
Session.commit()
r = flask_client.get(
url_for("auth.change_email", code=email_change_code),
follow_redirects=True,
)
assert r.status_code == 200
user = User.get(user_id)
assert user.email == new_email
assert EmailChange.get(email_change_id) is None
assert ResetPasswordCode.get(reset_id) is None

View File

@ -0,0 +1,82 @@
from flask import url_for
from app.db import Session
from app.utils import canonicalize_email, random_string
from tests.utils import create_new_user
def test_unactivated_user_login(flask_client):
user = create_new_user()
user.activated = False
Session.commit()
r = flask_client.post(
url_for("auth.login"),
data={"email": user.email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
assert (
b"Please check your inbox for the activation email. You can also have this email re-sent"
in r.data
)
def test_non_canonical_login(flask_client):
email = f"pre.{random_string(10)}@gmail.com"
name = f"NAME-{random_string(10)}"
user = create_new_user(email, name)
Session.commit()
r = flask_client.post(
url_for("auth.login"),
data={"email": user.email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
assert name.encode("utf-8") in r.data
canonical_email = canonicalize_email(email)
assert canonical_email != email
flask_client.get(url_for("auth.logout"))
r = flask_client.post(
url_for("auth.login"),
data={"email": canonical_email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
assert name.encode("utf-8") not in r.data
def test_canonical_login_with_non_canonical_email(flask_client):
suffix = f"{random_string(10)}@gmail.com"
canonical_email = f"pre{suffix}"
non_canonical_email = f"pre.{suffix}"
name = f"NAME-{random_string(10)}"
create_new_user(canonical_email, name)
Session.commit()
r = flask_client.post(
url_for("auth.login"),
data={"email": non_canonical_email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
assert name.encode("utf-8") in r.data
flask_client.get(url_for("auth.logout"))
r = flask_client.post(
url_for("auth.login"),
data={"email": canonical_email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
assert name.encode("utf-8") in r.data

View File

@ -0,0 +1,23 @@
from flask import url_for
from urllib.parse import parse_qs
from urllib3.util import parse_url
from app.config import URL, PROTON_CLIENT_ID
def test_login_with_proton(flask_client):
r = flask_client.get(
url_for("auth.proton_login"),
follow_redirects=False,
)
location = r.headers.get("Location")
assert location is not None
parsed = parse_url(location)
query = parse_qs(parsed.query)
expected_redirect_url = f"{URL}/auth/proton/callback"
assert "code" == query["response_type"][0]
assert PROTON_CLIENT_ID == query["client_id"][0]
assert expected_redirect_url == query["redirect_uri"][0]

View File

@ -0,0 +1,88 @@
from flask import url_for
from app import config
from app.db import Session
from app.models import DailyMetric, User
from app.utils import canonicalize_email
from tests.utils import create_new_user, random_email
def setup_module():
config.SKIP_MX_LOOKUP_ON_CHECK = True
def teardown_module():
config.SKIP_MX_LOOKUP_ON_CHECK = False
def test_register_success(flask_client):
email = random_email()
r = flask_client.post(
url_for("auth.register"),
data={"email": email, "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
# User arrives at the waiting activation page.
assert b"An email to validate your email is on its way" in r.data
def test_register_increment_nb_new_web_non_proton_user(flask_client):
daily_metric = DailyMetric.get_or_create_today_metric()
Session.commit()
nb_new_web_non_proton_user = daily_metric.nb_new_web_non_proton_user
r = flask_client.post(
url_for("auth.register"),
data={"email": random_email(), "password": "password"},
follow_redirects=True,
)
assert r.status_code == 200
new_daily_metric = DailyMetric.get_or_create_today_metric()
assert new_daily_metric.nb_new_web_non_proton_user == nb_new_web_non_proton_user + 1
def test_register_disabled(flask_client):
"""User cannot create new account when DISABLE_REGISTRATION."""
config.DISABLE_REGISTRATION = True
r = flask_client.post(
url_for("auth.register"),
data={"email": "abcd@gmail.com", "password": "password"},
follow_redirects=True,
)
config.DISABLE_REGISTRATION = False
assert b"Registration is closed" in r.data
def test_register_non_canonical_if_canonical_exists_is_not_allowed(flask_client):
"""User cannot create new account if the canonical name clashes"""
email = f"noncan.{random_email()}"
canonical_email = canonicalize_email(email)
create_new_user(email=canonical_email)
r = flask_client.post(
url_for("auth.register"),
data={"email": email, "password": "password"},
follow_redirects=True,
)
assert f"Email {canonical_email} already used".encode("utf-8") in r.data
def test_register_non_canonical_is_canonicalized(flask_client):
"""User cannot create new account if the canonical name clashes"""
email = f"noncan.{random_email()}"
r = flask_client.post(
url_for("auth.register"),
data={"email": email, "password": "password"},
follow_redirects=True,
)
assert b"An email to validate your email is on its way" in r.data
assert User.get_by(email=canonicalize_email(email)) is not None

64
app/tests/conftest.py Normal file
View File

@ -0,0 +1,64 @@
import os
# use the tests/test.env config fle
# flake8: noqa: E402
os.environ["CONFIG"] = os.path.abspath(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests/test.env")
)
import sqlalchemy
from app.db import Session, engine, connection
from psycopg2 import errors
from psycopg2.errorcodes import DEPENDENT_OBJECTS_STILL_EXIST
import pytest
from server import create_app
from init_app import add_sl_domains, add_proton_partner
app = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SERVER_NAME"] = "sl.test"
# enable pg_trgm extension
with engine.connect() as conn:
try:
conn.execute("DROP EXTENSION if exists pg_trgm")
conn.execute("CREATE EXTENSION pg_trgm")
except sqlalchemy.exc.InternalError as e:
if isinstance(e.orig, errors.lookup(DEPENDENT_OBJECTS_STILL_EXIST)):
print(">>> pg_trgm can't be dropped, ignore")
conn.execute("Rollback")
add_sl_domains()
add_proton_partner()
@pytest.fixture
def flask_app():
yield app
from app import config
@pytest.fixture
def flask_client():
transaction = connection.begin()
with app.app_context():
# disable rate limit during test
config.DISABLE_RATE_LIMIT = True
try:
client = app.test_client()
yield client
finally:
# disable rate limit again as some tests might enable rate limit
config.DISABLE_RATE_LIMIT = True
# roll back all commits made during a test
transaction.rollback()
Session.rollback()
Session.close()

View File

View File

@ -0,0 +1,59 @@
from flask import url_for
from app.models import (
Alias,
Contact,
)
from tests.utils import login
def test_add_contact_success(flask_client):
user = login(flask_client)
alias = Alias.filter(Alias.user_id == user.id).first()
assert Contact.filter_by(user_id=user.id).count() == 0
# <<< Create a new contact >>>
flask_client.post(
url_for("dashboard.alias_contact_manager", alias_id=alias.id),
data={
"form-name": "create",
"email": "abcd@gmail.com",
},
follow_redirects=True,
)
# a new contact is added
assert Contact.filter_by(user_id=user.id).count() == 1
contact = Contact.filter_by(user_id=user.id).first()
assert contact.website_email == "abcd@gmail.com"
# <<< Create a new contact using a full email format >>>
flask_client.post(
url_for("dashboard.alias_contact_manager", alias_id=alias.id),
data={
"form-name": "create",
"email": "First Last <another@gmail.com>",
},
follow_redirects=True,
)
# a new contact is added
assert Contact.filter_by(user_id=user.id).count() == 2
contact = (
Contact.filter_by(user_id=user.id).filter(Contact.id != contact.id).first()
)
assert contact.website_email == "another@gmail.com"
assert contact.name == "First Last"
# <<< Create a new contact with invalid email address >>>
r = flask_client.post(
url_for("dashboard.alias_contact_manager", alias_id=alias.id),
data={
"form-name": "create",
"email": "with space@gmail.com",
},
follow_redirects=True,
)
# no new contact is added
assert Contact.filter_by(user_id=user.id).count() == 2
assert "Invalid email format. Email must be either email@example.com" in str(r.data)

View File

@ -0,0 +1,5 @@
from tests.utils_test_alias import alias_export
def test_alias_export(flask_client):
alias_export(flask_client, "dashboard.alias_export_route")

View File

@ -0,0 +1,38 @@
from app.dashboard.views import alias_transfer
from app.db import Session
from app.models import (
Alias,
Mailbox,
User,
AliasMailbox,
)
from tests.utils import login
def test_alias_transfer(flask_client):
user = login(flask_client)
mb = Mailbox.create(user_id=user.id, email="mb@gmail.com", commit=True)
alias = Alias.create_new_random(user)
Session.commit()
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id, commit=True)
new_user = User.create(
email="hey@example.com",
password="password",
activated=True,
commit=True,
)
Mailbox.create(
user_id=new_user.id, email="hey2@example.com", verified=True, commit=True
)
alias_transfer.transfer(alias, new_user, new_user.mailboxes())
# refresh from db
alias = Alias.get(alias.id)
assert alias.user == new_user
assert set(alias.mailboxes) == set(new_user.mailboxes())
assert len(alias.mailboxes) == 2

View File

@ -0,0 +1,89 @@
from time import time
from flask import url_for
from app.db import Session
from app.models import User, ApiKey
from tests.utils import login
def test_api_key_page_requires_password(flask_client):
r = flask_client.get(
url_for("dashboard.api_key"),
)
assert r.status_code == 302
def test_create_delete_api_key(flask_client):
user = login(flask_client)
nb_api_key = ApiKey.count()
# to bypass sudo mode
with flask_client.session_transaction() as session:
session["sudo_time"] = int(time())
# create api_key
create_r = flask_client.post(
url_for("dashboard.api_key"),
data={"form-name": "create", "name": "for test"},
follow_redirects=True,
)
assert create_r.status_code == 200
api_key = ApiKey.get_by(user_id=user.id)
assert ApiKey.filter(ApiKey.user_id == user.id).count() == 1
assert api_key.name == "for test"
# delete api_key
delete_r = flask_client.post(
url_for("dashboard.api_key"),
data={"form-name": "delete", "api-key-id": api_key.id},
follow_redirects=True,
)
assert delete_r.status_code == 200
assert ApiKey.count() == nb_api_key
def test_delete_all_api_keys(flask_client):
nb_api_keys = ApiKey.count()
# create two test users
user_1 = login(flask_client)
user_2 = User.create(
email="a2@b.c", password="password", name="Test User 2", activated=True
)
Session.commit()
# create api_key for both users
ApiKey.create(user_1.id, "for test")
ApiKey.create(user_1.id, "for test 2")
ApiKey.create(user_2.id, "for test")
Session.commit()
assert (
ApiKey.count() == nb_api_keys + 3
) # assert that the total number of API keys for all users is 3.
# assert that each user has the API keys created
assert ApiKey.filter(ApiKey.user_id == user_1.id).count() == 2
assert ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1
# to bypass sudo mode
with flask_client.session_transaction() as session:
session["sudo_time"] = int(time())
# delete all of user 1's API keys
r = flask_client.post(
url_for("dashboard.api_key"),
data={"form-name": "delete-all"},
follow_redirects=True,
)
assert r.status_code == 200
assert (
ApiKey.count() == nb_api_keys + 1
) # assert that the total number of API keys for all users is now 1.
assert (
ApiKey.filter(ApiKey.user_id == user_1.id).count() == 0
) # assert that user 1 now has 0 API keys
assert (
ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1
) # assert that user 2 still has 1 API key

View File

@ -0,0 +1,391 @@
from random import random
from flask import url_for, g
from app import config
from app.alias_suffix import (
get_alias_suffixes,
AliasSuffix,
signer,
verify_prefix_suffix,
)
from app.alias_utils import delete_alias
from app.config import EMAIL_DOMAIN
from app.db import Session
from app.models import (
Mailbox,
CustomDomain,
Alias,
DomainDeletedAlias,
DeletedAlias,
SLDomain,
DailyMetric,
)
from app.utils import random_word
from tests.utils import login, random_domain, create_new_user
def test_add_alias_success(flask_client):
user = login(flask_client)
suffix = f".{int(random() * 100000)}@{EMAIL_DOMAIN}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
# create with a single mailbox
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Alias prefix{alias_suffix.suffix} has been created" in str(r.data)
alias = Alias.order_by(Alias.created_at.desc()).first()
assert not alias._mailboxes
def test_add_alias_increment_nb_daily_metric_alias(flask_client):
user = login(flask_client)
daily_metric = DailyMetric.get_or_create_today_metric()
Session.commit()
nb_alias = daily_metric.nb_alias
suffix = f".{int(random() * 100000)}@{EMAIL_DOMAIN}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
# create with a single mailbox
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
new_daily_metric = DailyMetric.get_or_create_today_metric()
assert new_daily_metric.nb_alias == nb_alias + 1
def test_add_alias_multiple_mailboxes(flask_client):
user = login(flask_client)
Session.commit()
suffix = f".{int(random() * 100000)}@{EMAIL_DOMAIN}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
# create with a multiple mailboxes
mb1 = Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
Session.commit()
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id, mb1.id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Alias prefix{alias_suffix.suffix} has been created" in str(r.data)
alias = Alias.order_by(Alias.created_at.desc()).first()
assert alias._mailboxes
def test_not_show_unverified_mailbox(flask_client):
"""make sure user unverified mailbox is not shown to user"""
user = login(flask_client)
Session.commit()
Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
Mailbox.create(user_id=user.id, email="m2@example.com", verified=False)
Session.commit()
r = flask_client.get(url_for("dashboard.custom_alias"))
assert "m1@example.com" in str(r.data)
assert "m2@example.com" not in str(r.data)
def test_verify_prefix_suffix(flask_client):
user = login(flask_client)
Session.commit()
CustomDomain.create(user_id=user.id, domain="test.com", ownership_verified=True)
assert verify_prefix_suffix(user, "prefix", "@test.com")
assert not verify_prefix_suffix(user, "prefix", "@abcd.com")
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
assert verify_prefix_suffix(user, "prefix", suffix)
def test_available_suffixes(flask_client):
user = login(flask_client)
CustomDomain.create(user_id=user.id, domain="test.com", ownership_verified=True)
assert len(get_alias_suffixes(user)) > 0
# first suffix is custom domain
first_suffix = get_alias_suffixes(user)[0]
assert first_suffix.is_custom
assert first_suffix.suffix == "@test.com"
assert first_suffix.signed_suffix.startswith("@test.com")
def test_available_suffixes_default_domain(flask_client):
user = login(flask_client)
sl_domain = SLDomain.first()
CustomDomain.create(
user_id=user.id, domain="test.com", ownership_verified=True, commit=True
)
user.default_alias_public_domain_id = sl_domain.id
# first suffix is SL Domain
first_suffix = get_alias_suffixes(user)[0]
assert first_suffix.suffix.endswith(f"@{sl_domain.domain}")
user.default_alias_public_domain_id = None
# first suffix is custom domain
first_suffix = get_alias_suffixes(user)[0]
assert first_suffix.suffix == "@test.com"
def test_available_suffixes_random_prefix_generation(flask_client):
user = login(flask_client)
CustomDomain.create(
user_id=user.id, domain="test.com", ownership_verified=True, commit=True
)
cd2 = CustomDomain.create(
user_id=user.id, domain="test2.com", ownership_verified=True, commit=True
)
user.default_alias_custom_domain_id = cd2.id
# first suffix is test2.com
first_suffix = get_alias_suffixes(user)[0]
assert first_suffix.suffix == "@test2.com"
cd2.random_prefix_generation = True
# e.g. .meo@test2.com
first_suffix = get_alias_suffixes(user)[0]
assert first_suffix.suffix.endswith("@test2.com")
assert first_suffix.suffix.startswith(".")
def test_available_suffixes_hidden_domain(flask_client):
user = login(flask_client)
nb_suffix = len(get_alias_suffixes(user))
sl_domain = SLDomain.create(domain=random_domain(), commit=True)
assert len(get_alias_suffixes(user)) == nb_suffix + 1
sl_domain.hidden = True
Session.commit()
assert len(get_alias_suffixes(user)) == nb_suffix
def test_available_suffixes_domain_order(flask_client):
user = login(flask_client)
domain = random_domain()
# will be the last domain as other domains have order=0
sl_domain = SLDomain.create(domain=domain, order=1, commit=True)
last_suffix_info = get_alias_suffixes(user)[-1]
assert last_suffix_info.suffix.endswith(domain)
# now will be the first domain
sl_domain.order = -1
Session.commit()
first_suffix_info = get_alias_suffixes(user)[0]
assert first_suffix_info.suffix.endswith(domain)
def test_add_already_existed_alias(flask_client):
user = login(flask_client)
Session.commit()
another_user = create_new_user()
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
# alias already exist
Alias.create(
user_id=another_user.id,
email=f"prefix{suffix}",
mailbox_id=another_user.default_mailbox_id,
commit=True,
)
# create the same alias, should return error
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"prefix{suffix} cannot be used" in r.get_data(True)
def test_add_alias_in_global_trash(flask_client):
user = login(flask_client)
Session.commit()
another_user = create_new_user()
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
# delete an alias: alias should go the DeletedAlias
alias = Alias.create(
user_id=another_user.id,
email=f"prefix{suffix}",
mailbox_id=another_user.default_mailbox_id,
commit=True,
)
prev_deleted = DeletedAlias.count()
delete_alias(alias, another_user)
assert prev_deleted + 1 == DeletedAlias.count()
# create the same alias, should return error
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"prefix{suffix} cannot be used" in r.get_data(True)
def test_add_alias_in_custom_domain_trash(flask_client):
user = login(flask_client)
domain = random_domain()
custom_domain = CustomDomain.create(
user_id=user.id, domain=domain, ownership_verified=True, commit=True
)
# delete a custom-domain alias: alias should go the DomainDeletedAlias
alias = Alias.create(
user_id=user.id,
email=f"prefix@{domain}",
custom_domain_id=custom_domain.id,
mailbox_id=user.default_mailbox_id,
commit=True,
)
assert DomainDeletedAlias.count() == 0
delete_alias(alias, user)
assert DomainDeletedAlias.count() == 1
# create the same alias, should return error
suffix = f"@{domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=EMAIL_DOMAIN,
)
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"signed-alias-suffix": alias_suffix.signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert "You have deleted this alias before. You can restore it on" in r.get_data(
True
)
def test_too_many_requests(flask_client):
config.DISABLE_RATE_LIMIT = False
user = login(flask_client)
# create a custom domain
domain = random_domain()
CustomDomain.create(user_id=user.id, domain=domain, verified=True, commit=True)
# can't create more than 5 aliases in 1 minute
for i in range(7):
signed_suffix = signer.sign(f"@{domain}").decode()
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": f"prefix{i}",
"suffix": signed_suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
# to make flask-limiter work with unit test
# https://github.com/alisaifee/flask-limiter/issues/147#issuecomment-642683820
g._rate_limiting_complete = False
else:
# last request
assert r.status_code == 429
assert "Whoa, slow down there, pardner!" in str(r.data)

View File

@ -0,0 +1,61 @@
from flask import url_for
from app.db import Session
from app.email_utils import get_email_domain_part
from app.models import Mailbox
from tests.utils import login, random_domain
def test_add_domain_success(flask_client):
user = login(flask_client)
user.lifetime = True
Session.commit()
domain = random_domain()
r = flask_client.post(
url_for("dashboard.custom_domain"),
data={"form-name": "create", "domain": domain},
follow_redirects=True,
)
assert r.status_code == 200
assert f"New domain {domain} is created".encode() in r.data
def test_add_domain_same_as_user_email(flask_client):
"""cannot add domain if user personal email uses this domain"""
user = login(flask_client)
user.lifetime = True
Session.commit()
r = flask_client.post(
url_for("dashboard.custom_domain"),
data={"form-name": "create", "domain": get_email_domain_part(user.email)},
follow_redirects=True,
)
assert r.status_code == 200
assert (
b"You cannot add a domain that you are currently using for your personal email"
in r.data
)
def test_add_domain_used_in_mailbox(flask_client):
"""cannot add domain if it has been used in a verified mailbox"""
user = login(flask_client)
user.lifetime = True
Session.commit()
Mailbox.create(
user_id=user.id, email="mailbox@new-domain.com", verified=True, commit=True
)
r = flask_client.post(
url_for("dashboard.custom_domain"),
data={"form-name": "create", "domain": "new-domain.com"},
follow_redirects=True,
)
assert r.status_code == 200
assert b"new-domain.com already used in a SimpleLogin mailbox" in r.data

View File

@ -0,0 +1,84 @@
from flask import url_for
from app.config import MAX_NB_DIRECTORY
from app.models import Directory
from tests.utils import login, random_token
def test_create_directory(flask_client):
login(flask_client)
directory_name = random_token()
r = flask_client.post(
url_for("dashboard.directory"),
data={"form-name": "create", "name": directory_name},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Directory {directory_name} is created" in r.data.decode()
assert Directory.get_by(name=directory_name) is not None
def test_delete_directory(flask_client):
"""cannot add domain if user personal email uses this domain"""
user = login(flask_client)
directory_name = random_token()
directory = Directory.create(name=directory_name, user_id=user.id, commit=True)
r = flask_client.post(
url_for("dashboard.directory"),
data={"form-name": "delete", "directory_id": directory.id},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Directory {directory_name} has been deleted" in r.data.decode()
assert Directory.get_by(name=directory_name) is None
def test_create_directory_in_trash(flask_client):
user = login(flask_client)
directory_name = random_token()
directory = Directory.create(name=directory_name, user_id=user.id, commit=True)
# delete the directory
r = flask_client.post(
url_for("dashboard.directory"),
data={"form-name": "delete", "directory_id": directory.id},
follow_redirects=True,
)
assert Directory.get_by(name=directory_name) is None
# try to recreate the directory
r = flask_client.post(
url_for("dashboard.directory"),
data={"form-name": "create", "name": directory_name},
follow_redirects=True,
)
assert r.status_code == 200
assert (
f"{directory_name} has been used before and cannot be reused" in r.data.decode()
)
def test_create_directory_out_of_quota(flask_client):
user = login(flask_client)
for i in range(
MAX_NB_DIRECTORY - Directory.filter(Directory.user_id == user.id).count()
):
Directory.create(name=f"test{i}", user_id=user.id, commit=True)
assert Directory.filter(Directory.user_id == user.id).count() == MAX_NB_DIRECTORY
flask_client.post(
url_for("dashboard.directory"),
data={"form-name": "create", "name": "test"},
follow_redirects=True,
)
# no new directory is created
assert Directory.filter(Directory.user_id == user.id).count() == MAX_NB_DIRECTORY

View File

@ -0,0 +1,41 @@
from flask import url_for, g
from app import config
from app.models import (
Alias,
)
from tests.utils import login
def test_create_random_alias_success(flask_client):
user = login(flask_client)
assert Alias.filter(Alias.user_id == user.id).count() == 1
r = flask_client.post(
url_for("dashboard.index"),
data={"form-name": "create-random-email"},
follow_redirects=True,
)
assert r.status_code == 200
assert Alias.filter(Alias.user_id == user.id).count() == 2
def test_too_many_requests(flask_client):
config.DISABLE_RATE_LIMIT = False
login(flask_client)
# can't create more than 5 aliases in 1 minute
for _ in range(7):
r = flask_client.post(
url_for("dashboard.index"),
data={"form-name": "create-random-email"},
follow_redirects=True,
)
# to make flask-limiter work with unit test
# https://github.com/alisaifee/flask-limiter/issues/147#issuecomment-642683820
g._rate_limiting_complete = False
else:
# last request
assert r.status_code == 429
assert "Whoa, slow down there, pardner!" in str(r.data)

View File

@ -0,0 +1,28 @@
from flask import url_for
from app import config
from app.models import EmailChange
from app.utils import canonicalize_email
from tests.utils import login, random_email, create_new_user
def test_setup_done(flask_client):
config.SKIP_MX_LOOKUP_ON_CHECK = True
user = create_new_user()
login(flask_client, user)
noncanonical_email = f"nonca.{random_email()}"
r = flask_client.post(
url_for("dashboard.setting"),
data={
"form-name": "update-email",
"email": noncanonical_email,
},
follow_redirects=True,
)
assert r.status_code == 200
email_change = EmailChange.get_by(user_id=user.id)
assert email_change is not None
assert email_change.new_email == canonicalize_email(noncanonical_email)
config.SKIP_MX_LOOKUP_ON_CHECK = False

View File

@ -0,0 +1,16 @@
from flask import url_for
from tests.utils import login
def test_setup_done(flask_client):
login(flask_client)
r = flask_client.get(
url_for("dashboard.setup_done"),
)
assert r.status_code == 302
# user is redirected to the dashboard page
assert r.headers["Location"].endswith("/dashboard/")
assert "setup_done=true" in r.headers["Set-Cookie"]

View File

@ -0,0 +1,141 @@
from flask import url_for
from app.config import MAX_NB_SUBDOMAIN
from app.db import Session
from app.models import SLDomain, CustomDomain, Job
from tests.utils import login
def setup_sl_domain() -> SLDomain:
"""Take the first SLDomain and set its can_use_subdomain=True"""
sl_domain: SLDomain = SLDomain.first()
sl_domain.can_use_subdomain = True
Session.commit()
return sl_domain
def test_create_subdomain(flask_client):
login(flask_client)
sl_domain = setup_sl_domain()
r = flask_client.post(
url_for("dashboard.subdomain_route"),
data={"form-name": "create", "subdomain": "test", "domain": sl_domain.domain},
follow_redirects=True,
)
assert r.status_code == 200
assert f"New subdomain test.{sl_domain.domain} is created" in r.data.decode()
assert CustomDomain.get_by(domain=f"test.{sl_domain.domain}") is not None
def test_delete_subdomain(flask_client):
user = login(flask_client)
sl_domain = setup_sl_domain()
subdomain = CustomDomain.create(
domain=f"test.{sl_domain.domain}",
user_id=user.id,
is_sl_subdomain=True,
commit=True,
)
nb_job = Job.count()
r = flask_client.post(
url_for("dashboard.domain_detail", custom_domain_id=subdomain.id),
data={"form-name": "delete"},
follow_redirects=True,
)
assert r.status_code == 200
assert f"test.{sl_domain.domain} scheduled for deletion." in r.data.decode()
# a domain deletion job is scheduled
assert Job.count() == nb_job + 1
def test_create_subdomain_in_trash(flask_client):
user = login(flask_client)
sl_domain = setup_sl_domain()
subdomain = CustomDomain.create(
domain=f"test.{sl_domain.domain}",
user_id=user.id,
is_sl_subdomain=True,
commit=True,
)
# delete the subdomain
CustomDomain.delete(subdomain.id)
assert CustomDomain.get_by(domain=f"test.{sl_domain.domain}") is None
r = flask_client.post(
url_for("dashboard.subdomain_route"),
data={"form-name": "create", "subdomain": "test", "domain": sl_domain.domain},
follow_redirects=True,
)
assert r.status_code == 200
assert (
f"test.{sl_domain.domain} has been used before and cannot be reused"
in r.data.decode()
)
def test_create_subdomain_out_of_quota(flask_client):
user = login(flask_client)
sl_domain = setup_sl_domain()
for i in range(MAX_NB_SUBDOMAIN):
CustomDomain.create(
domain=f"test{i}.{sl_domain.domain}",
user_id=user.id,
is_sl_subdomain=True,
commit=True,
)
assert CustomDomain.filter_by(user_id=user.id).count() == MAX_NB_SUBDOMAIN
flask_client.post(
url_for("dashboard.subdomain_route"),
data={"form-name": "create", "subdomain": "test", "domain": sl_domain.domain},
follow_redirects=True,
)
# no new subdomain is created
assert CustomDomain.filter_by(user_id=user.id).count() == MAX_NB_SUBDOMAIN
def test_create_subdomain_invalid(flask_client):
user = login(flask_client)
sl_domain = setup_sl_domain()
# subdomain can't end with dash (-)
flask_client.post(
url_for("dashboard.subdomain_route"),
data={"form-name": "create", "subdomain": "test-", "domain": sl_domain.domain},
follow_redirects=True,
)
assert CustomDomain.filter_by(user_id=user.id).count() == 0
# subdomain can't contain underscore (_)
flask_client.post(
url_for("dashboard.subdomain_route"),
data={
"form-name": "create",
"subdomain": "test_test",
"domain": sl_domain.domain,
},
follow_redirects=True,
)
assert CustomDomain.filter_by(user_id=user.id).count() == 0
# subdomain must have at least 3 characters
flask_client.post(
url_for("dashboard.subdomain_route"),
data={"form-name": "create", "subdomain": "te", "domain": sl_domain.domain},
follow_redirects=True,
)
assert CustomDomain.filter_by(user_id=user.id).count() == 0

View File

@ -0,0 +1,35 @@
from app.db import Session
from app.models import (
Alias,
Contact,
)
from tests.utils import login
def test_disable_alias(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
assert alias.enabled
flask_client.post(f"/dashboard/unsubscribe/{alias.id}")
assert not alias.enabled
def test_block_contact(flask_client):
user = login(flask_client)
alias = Alias.first()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="re1@SL",
commit=True,
)
assert not contact.block_forward
flask_client.post(f"/dashboard/block_contact/{contact.id}")
assert contact.block_forward
# make sure the page loads
flask_client.get(f"/dashboard/block_contact/{contact.id}")

BIN
app/tests/data/1px.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

BIN
app/tests/data/1px.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

View File

View File

@ -0,0 +1,109 @@
import random
from app.config import (
MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS,
MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX,
)
from app.db import Session
from app.email.rate_limit import (
rate_limited_forward_phase,
rate_limited_for_alias,
rate_limited_for_mailbox,
rate_limited_reply_phase,
)
from app.models import Alias, EmailLog, Contact
from tests.utils import create_new_user
def test_rate_limited_forward_phase_for_alias(flask_client):
user = create_new_user()
# no rate limiting for a new alias
alias = Alias.create_new_random(user)
Session.commit()
assert not rate_limited_for_alias(alias)
# rate limit when there's a previous activity on alias
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
)
Session.commit()
for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
)
Session.commit()
assert rate_limited_for_alias(alias)
def test_rate_limited_forward_phase_for_mailbox(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
)
Session.commit()
for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_MAILBOX + 1):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
)
Session.commit()
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
)
# Create another alias with the same mailbox
# will be rate limited as there's a previous activity on mailbox
alias2 = Alias.create_new_random(user)
Session.commit()
assert rate_limited_for_mailbox(alias2)
def test_rate_limited_forward_phase(flask_client):
# no rate limiting when alias does not exist
assert not rate_limited_forward_phase("not-exist@alias.com")
def test_rate_limited_reply_phase(flask_client):
# no rate limiting when reply_email does not exist
assert not rate_limited_reply_phase("not-exist-reply@alias.com")
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
reply_email = f"reply-{random.random()}@sl.local"
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email=reply_email,
)
Session.commit()
for _ in range(MAX_ACTIVITY_DURING_MINUTE_PER_ALIAS + 1):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
)
Session.commit()
assert rate_limited_reply_phase(reply_email)

View File

@ -0,0 +1,28 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: somewhere@rainbow.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_NA(0.10);
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
TO_DN_NONE(0.00)[];
{{ spf_result }}(0.00[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
This is a test mailing

View File

@ -0,0 +1,27 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_ALLOW(0.10);
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
This is a test mailing

View File

@ -0,0 +1,27 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_BAD_POLICY(0.10);
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
This is a test mailing

View File

@ -0,0 +1,41 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [WRONGLY_FORMATTED / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_SOFTFAIL(0.10)[gmail.com : No valid SPF, No valid DKIM,none];
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
FROM_EQ_ENVFROM(0.00)[];
ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR];
R_DKIM_NA(0.00)[];
RCVD_COUNT_ZERO(0.00)[0];
FREEMAIL_ENVFROM(0.00)[gmail.com];
FROM_NO_DN(0.00)[];
R_SPF_SOFTFAIL(0.00)[~all];
FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_FROM(0.00)[gmail.com];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,41 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_SOFTFAIL(0.10)[gmail.com : No valid SPF, No valid DKIM,none];
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
FROM_EQ_ENVFROM(0.00)[];
ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR];
R_DKIM_NA(0.00)[];
RCVD_COUNT_ZERO(0.00)[0];
FREEMAIL_ENVFROM(0.00)[gmail.com];
FROM_NO_DN(0.00)[];
R_SPF_SOFTFAIL(0.00)[~all];
FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_FROM(0.00)[gmail.com];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,27 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_NA(0.10);
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
This is a test mailing

View File

@ -0,0 +1,41 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_QUARANTINE(0.10)[gmail.com : No valid SPF, No valid DKIM,none];
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
FROM_EQ_ENVFROM(0.00)[];
ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR];
R_DKIM_NA(0.00)[];
RCVD_COUNT_ZERO(0.00)[0];
FREEMAIL_ENVFROM(0.00)[gmail.com];
FROM_NO_DN(0.00)[];
R_SPF_SOFTFAIL(0.00)[~all];
FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_FROM(0.00)[gmail.com];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,41 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_REJECT(0.10)[gmail.com : No valid SPF, No valid DKIM,none];
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
FROM_EQ_ENVFROM(0.00)[];
ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR];
R_DKIM_NA(0.00)[];
RCVD_COUNT_ZERO(0.00)[0];
FREEMAIL_ENVFROM(0.00)[gmail.com];
FROM_NO_DN(0.00)[];
R_SPF_SOFTFAIL(0.00)[~all];
FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_FROM(0.00)[gmail.com];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,25 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ contact_email }}
From: {{ alias_email }}
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
{{ dmarc_result }}(0.00)[];
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,42 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: wehrman_mannequin@sldev.ovh
From: spoofedemailsource@gmail.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: INVALIDVALUE
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
X-Spamd-Result: default: False [0.50 / 13.00];
MID_RHS_NOT_FQDN(0.50)[];
DMARC_POLICY_SOFTFAIL(0.10)[gmail.com : No valid SPF, No valid DKIM,none];
MIME_GOOD(-0.10)[text/plain];
MIME_TRACE(0.00)[0:+];
FROM_EQ_ENVFROM(0.00)[];
ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR];
R_DKIM_NA(0.00)[];
RCVD_COUNT_ZERO(0.00)[0];
FREEMAIL_ENVFROM(0.00)[gmail.com];
FROM_NO_DN(0.00)[];
R_SPF_SOFTFAIL(0.00)[~all];
FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_FROM(0.00)[gmail.com];
TO_DN_NONE(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
ARC_NA(0.00)[]
X-Rspamd-Pre-Result: action=add header;
module=force_actions;
unknown reason
X-Spam: Yes
This is a test mailing

View File

@ -0,0 +1,258 @@
X-SimpleLogin-Client-IP: 40.92.66.13
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=40.92.66.13;
helo=eur01-ve1-obe.outbound.protection.outlook.com;
envelope-from=staff@hotmail.com; receiver=<UNKNOWN>
Received: from EUR01-VE1-obe.outbound.protection.outlook.com
(mail-oln040092066013.outbound.protection.outlook.com [40.92.66.13])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by prod4.simplelogin.co (Postfix) with ESMTPS id 408E09C472
for <{{ postmaster }}>; Mon, 9 May 2022 13:11:34 +0000 (UTC)
ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none;
b=V3N8KdYGgYrjs5KcjFUA0MgPUmOc+NV4ygLfSd7fehfiNemKdhe6Cpfj58zWFNzoG5qBoUCIm/BI7aCr7lqAU2hQJypTrJG+3zbSdnuCKMBVV5GHZxkE+XAeSU+4wt4xwl1ZiVx/2P//xUVWN/TVmiuKUgCn9n+WagU9LYGVT9z6wwOpXggpDf6ow9RnJDPJpkakHRh7rQPABbrOpVqEZnoJdAH5mgdTHJOeBumNym4i3GKnky+IfMlqwGcbTrzgrt/D3PpZdsMG4B+jEHtTo3FgB9JY+abjU9Bvn4rXwKr3RMF+1ZV3UsznQVwuT99PtfEcExV3zSsqEPDBy9QT9w==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;
s=arcselector9901;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1;
bh=Y37p6EaXY5hpBNgMr1ILYzy35GKdkqWXm69FR2RyQgA=;
b=aet1P4fpmUM9bqbLD3vtp/EWfUi2WfvWbOnnLg/YZ2vxoTF/eM5IHDBB/I7btdzZICric+KkhRih/kvaVURGy4jybYjn9FNfT+HShTJa75Pk30fp3in/5lL2x6Q0xM0Naf9YtTvGgqlLDrdgCmktxyByNAOFPo27fEWy3fk/00IPWyI8j77VvYsGn8rJCLbhDUBWwGzQ9P7SabIqn9Ybx6CKcw2FssJhSNAyOIx7EkrGxq8y/5dXeWSHLFBdHPu6F9w/DKyt9cv17rBSnHo4tx1Ese93vBHT5XIwTwnGisCa0++eqL/69GugKoe5odkAfsdRAlBjVTgXp2Lol4rrpg==
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none;
dkim=none; arc=none
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=hotmail.com;
s=selector1;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=Y37p6EaXY5hpBNgMr1ILYzy35GKdkqWXm69FR2RyQgA=;
b=uMkd90Lx6ikNpk7RRBU3AfQ0jjbjRZAGQLnY3r+dQ3CNnhgfHxpNRudxGDydmf6GQ2AuylmOnLVATh8XMKTvCnVg8hjB9xrxd5qPpQ3k92U5VlgVe1o1Nwq8R6VCJugOZduDjSJdBXO2ACosUul6IQXKMBpSNq+bGJ9VHu63EGTphkWOOw1a4PArg8tQTSmkpkyh788nsfNXnVsh2fkL6we1LyvagQzTS4e1ynuSk1zAk+6U5KOuhRVr2Nh/AvyvswWpjA4pflOqFwyqsMYb3N6wnpRTct8CJUPlQwEx6chiJgKNGrAkdRbnWaEyeIEdyJB/NLwtPqZzKYFgv7f8wg==
Received: from AM6PR02CA0021.eurprd02.prod.outlook.com (2603:10a6:20b:6e::34)
by AM0PR02MB4563.eurprd02.prod.outlook.com (2603:10a6:208:ec::33) with
Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5227.22; Mon, 9 May
2022 13:11:32 +0000
Received: from AM6EUR05FT047.eop-eur05.prod.protection.outlook.com
(2603:10a6:20b:6e:cafe::26) by AM6PR02CA0021.outlook.office365.com
(2603:10a6:20b:6e::34) with Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5227.23 via Frontend
Transport; Mon, 9 May 2022 13:11:32 +0000
Received: from DM5SVC01SF077 (40.107.211.126) by
AM6EUR05FT047.mail.protection.outlook.com (10.233.241.167) with Microsoft
SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384) id
15.20.5227.15 via Frontend Transport; Mon, 9 May 2022 13:11:32 +0000
X-IncomingTopHeaderMarker:
OriginalChecksum:86053024C4DD515561A96BAF61AACB6F8A4DB30C8D14CAC5F2F7D189ACDCA109;UpperCasedChecksum:5323AB267D58619B82076460438A30DFDD8E7969870D76B723156F921928319B;SizeAsReceived:257;Count:6
Date: Mon, 9 May 2022 13:10:08 +0000
From: <staff@hotmail.com>
Subject: complaint about message from 176.119.200.162
To: {{ postmaster }}
MIME-Version: 1.0
Content-Type: multipart/mixed;
boundary="31A9507D-D0B3-4DCD-AFBB-413468892CFE"
X-IncomingHeaderCount: 6
Message-ID:
<1d63d9ee-8f3e-4876-955c-1807db5ad138@AM6EUR05FT047.eop-eur05.prod.protection.outlook.com>
X-EOPAttributedMessage: 0
X-MS-PublicTrafficType: Email
X-MS-Office365-Filtering-Correlation-Id: 44e9ec0b-6c5d-4cea-6417-08da31bd7000
X-MS-TrafficTypeDiagnostic: AM0PR02MB4563:EE_
X-Microsoft-Antispam: BCL:0;
X-Microsoft-Antispam-Message-Info:
lK5xD4UZS47NfR0tHc3wEp4HHOifZ4SDBb8aKx7H/vEW8Rg8rXXH12G4lWdpzr8qTsCmvzuhj5x6IAumOKQ8lWLj5Lp3jyml91wVnwCtUnk5cTXpQwDZd9QMgtEW07GoLdWjkbShAhLRDf+9Y4DxidHCacOAYxcNX42wo3vYZOEHDzVRUxSmY0c7Km60pDtiYzEk+P9AoE2YKYG2rDwDx0vgoLgqFspGqQ+2OeHD2ZAEyATHR/sQy6tf5S2d4wA3HcHrwrGMlz/4d9VbT5h9a5cqj9S59wpuc6g8nyYhmK3AHJkB5nXmpBZBihTw5X/Qh5PZqUYwPxkwpq3WlaEuXvzaKFiwJFvtuRGX+mEioClCxiwPROb7sI9ZHWPw48AHysF+whYGBfleRy4c2SuW6e1D5uewGry+lXVljxg7qKo=
X-OriginatorOrg: sct-15-20-4755-11-msonline-outlook-ab7de.templateTenant
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 09 May 2022 13:11:32.0875
(UTC)
X-MS-Exchange-CrossTenant-Network-Message-Id:
44e9ec0b-6c5d-4cea-6417-08da31bd7000
X-MS-Exchange-CrossTenant-Id: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa
X-MS-Exchange-CrossTenant-AuthSource:
AM6EUR05FT047.eop-eur05.prod.protection.outlook.com
X-MS-Exchange-CrossTenant-AuthAs: Anonymous
X-MS-Exchange-CrossTenant-FromEntityHeader: Internet
X-MS-Exchange-CrossTenant-RMS-PersistedConsumerOrg:
00000000-0000-0000-0000-000000000000
X-MS-Exchange-Transport-CrossTenantHeadersStamped: AM0PR02MB4563
X-Spamd-Result: default: False [-1.75 / 13.00];
ARC_ALLOW(-1.00)[microsoft.com:s=arcselector9901:i=1];
DMARC_POLICY_ALLOW(-0.50)[hotmail.com,none];
R_SPF_ALLOW(-0.20)[+ip4:40.92.0.0/15];
MIME_HTML_ONLY(0.20)[];
R_DKIM_ALLOW(-0.20)[hotmail.com:s=selector1];
MIME_GOOD(-0.10)[multipart/mixed,multipart/related];
MANY_INVISIBLE_PARTS(0.05)[1];
NEURAL_HAM(-0.00)[-0.996];
FROM_EQ_ENVFROM(0.00)[];
FREEMAIL_ENVFROM(0.00)[hotmail.com];
MIME_TRACE(0.00)[0:+,1:~,2:+,3:+,4:~];
ASN(0.00)[asn:8075, ipnet:40.80.0.0/12, country:US];
RCVD_IN_DNSWL_NONE(0.00)[40.92.66.13:from];
DKIM_TRACE(0.00)[hotmail.com:+];
RCVD_TLS_LAST(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
FREEMAIL_FROM(0.00)[hotmail.com];
FROM_NO_DN(0.00)[];
TO_DN_NONE(0.00)[];
RCVD_COUNT_THREE(0.00)[4];
RCPT_COUNT_ONE(0.00)[1];
DWL_DNSWL_NONE(0.00)[hotmail.com:dkim]
X-Rspamd-Queue-Id: 408E09C472
X-Rspamd-Server: prod4
Content-Transfer-Encoding: 7bit
--31A9507D-D0B3-4DCD-AFBB-413468892CFE
Content-Type: message/rfc822
Content-Disposition: inline
X-HmXmrOriginalRecipient: <jan.bailey2934@outlook.com>
X-MS-Exchange-EOPDirect: true
Received: from SJ0PR11MB4958.namprd11.prod.outlook.com (2603:10b6:a03:2ae::24)
by SA0PR11MB4525.namprd11.prod.outlook.com with HTTPS; Mon, 9 May 2022
04:30:48 +0000
Received: from BN9PR03CA0117.namprd03.prod.outlook.com (2603:10b6:408:fd::32)
by SJ0PR11MB4958.namprd11.prod.outlook.com (2603:10b6:a03:2ae::24) with
Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5227.20; Mon, 9 May
2022 04:30:45 +0000
Received: from BN8NAM11FT053.eop-nam11.prod.protection.outlook.com
(2603:10b6:408:fd:cafe::d0) by BN9PR03CA0117.outlook.office365.com
(2603:10b6:408:fd::32) with Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5227.20 via Frontend
Transport; Mon, 9 May 2022 04:30:45 +0000
Authentication-Results: spf=pass (sender IP is 176.119.200.162)
smtp.mailfrom=simplelogin.co; dkim=pass (signature was verified)
header.d=simplelogin.co;dmarc=pass action=none
header.from=simplelogin.co;compauth=pass reason=100
Received-SPF: Pass (protection.outlook.com: domain of simplelogin.co
designates 176.119.200.162 as permitted sender)
receiver=protection.outlook.com; client-ip=176.119.200.162;
helo=mail-200162.simplelogin.co;
Received: from mail-200162.simplelogin.co (176.119.200.162) by
BN8NAM11FT053.mail.protection.outlook.com (10.13.177.209) with Microsoft SMTP
Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id
15.20.5227.15 via Frontend Transport; Mon, 9 May 2022 04:30:44 +0000
X-IncomingTopHeaderMarker:
OriginalChecksum:5EBD8C309CA888838EDC898C63E28E1EC00EF74772276A54C08DA83D658756F4;UpperCasedChecksum:E102374CD208D4ACB2034F1A17F76DA6345BD176395C6D4EADEC3B47BFF41ECC;SizeAsReceived:1262;Count:15
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=simplelogin.co;
s=dkim; t=1652070640; h=From:To:Subject:Message-ID:Date;
bh=Tu2Q0oO5GuGw4CVxDAdixtRKr6jqMWjpH9zEf50uKwg=;
b=o6I0Ij1CahU9EUj/9uwWJpsDjfi/2gQIXT0KJT6IAK9hOoJ5bVqPsqtyGTfIoqYhhtD/ic
5NybKJmB6B6KL5hl5LG3KzCdaWfe3dAAhD4e2gIU80dal596dlzluyvLR1k+6rdM4JvlGq
OVWLR42Oj4anrnOqLCUkL44ILIhLpAE=
Date: Mon, 9 May 2022 00:30:38 -0400 (EDT)
Message-ID:
<10627474.1041327707.1652070638478.JavaMail.cloud@p2-mta-0301.p2.messagegears.net>
Subject: Original Subject
Content-Type: multipart/mixed;
boundary="----=_Part_1041327705_575167926.1652070638478"
Content-Transfer-Encoding: 7bit
X-SimpleLogin-Type: Forward
X-SimpleLogin-EmailLog-ID: 832832
X-SimpleLogin-Envelope-To: {{ rcpt }}
From: {{ sender }}
Reply-To: {{ sender }}
To: {{ rcpt_comma_list }}
List-Unsubscribe: <mailto:unsubscribe@simplelogin.co?subject=3134388=>
X-SimpleLogin-Want-Signing: yes
X-IncomingHeaderCount: 15
Return-Path: {{ return_path }}
X-MS-Exchange-Organization-ExpirationStartTime: 09 May 2022 04:30:45.1195
(UTC)
X-MS-Exchange-Organization-ExpirationStartTimeReason: OriginalSubmit
X-MS-Exchange-Organization-ExpirationInterval: 1:00:00:00.0000000
X-MS-Exchange-Organization-ExpirationIntervalReason: OriginalSubmit
X-MS-Exchange-Organization-Network-Message-Id:
ede92e41-5acb-4474-c5be-08da3174af2b
X-EOPAttributedMessage: 0
X-EOPTenantAttributedMessage: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa:0
X-MS-Exchange-Organization-MessageDirectionality: Incoming
X-MS-PublicTrafficType: Email
X-MS-Exchange-Organization-AuthSource:
BN8NAM11FT053.eop-nam11.prod.protection.outlook.com
X-MS-Exchange-Organization-AuthAs: Anonymous
X-MS-UserLastLogonTime: 5/9/2022 3:30:52 AM
X-MS-Office365-Filtering-Correlation-Id: ede92e41-5acb-4474-c5be-08da3174af2b
X-MS-TrafficTypeDiagnostic: SJ0PR11MB4958:EE_
X-MS-Exchange-EOPDirect: true
X-Sender-IP: 176.119.200.162
X-SID-PRA: PHWNQHFTTLQNZJXKMLHZCSKLLLJXMGEJOEOWW@SIMPLELOGIN.CO
X-SID-Result: PASS
X-MS-Exchange-Organization-PCL: 2
X-MS-Exchange-Organization-SCL: 1
X-Microsoft-Antispam: BCL:0;
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 09 May 2022 04:30:44.9945
(UTC)
X-MS-Exchange-CrossTenant-Network-Message-Id:
ede92e41-5acb-4474-c5be-08da3174af2b
X-MS-Exchange-CrossTenant-Id: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa
X-MS-Exchange-CrossTenant-AuthSource:
BN8NAM11FT053.eop-nam11.prod.protection.outlook.com
X-MS-Exchange-CrossTenant-AuthAs: Anonymous
X-MS-Exchange-CrossTenant-FromEntityHeader: Internet
X-MS-Exchange-CrossTenant-RMS-PersistedConsumerOrg:
00000000-0000-0000-0000-000000000000
X-MS-Exchange-Transport-CrossTenantHeadersStamped: SJ0PR11MB4958
X-MS-Exchange-Transport-EndToEndLatency: 00:00:03.3271765
X-MS-Exchange-Processed-By-BccFoldering: 15.20.5227.023
X-Microsoft-Antispam-Mailbox-Delivery:
abwl:0;wl:0;pcwl:0;kl:0;iwl:0;ijl:0;dwl:0;dkl:0;rwl:0;ucf:0;jmr:0;ex:0;auth:1;dest:I;ENG:(5062000285)(90000117)(90005022)(91005020)(91035115)(5061607266)(5061608174)(9050020)(9100338)(2008001134)(2008000189)(2008120399)(2008019284)(2008021020)(8390246)(8377080)(8386120)(4810004)(4910013)(9910022)(9510006)(10110021)(9320005);
X-Message-Info:
5vMbyqxGkdcvoPRAk5ACFywqndfpuBMcVz6K/12RtMALmdfGi+GpgO+lXQe3PiGwHtV5wXFRStQwg29XySZZo6tOyvshTSJ1uafhX53S93r5MaqDxJrR0UNGr2VYdKiAm1jYIYQm84v/mEbSAGjjBwEgS1PHlzM72I96JadXzfV9Fmsd5pHlfoLxEqXe6hBJAAQS99CcpwPDnaVA9UZUHA==
X-Message-Delivery: Vj0xLjE7dXM9MDtsPTA7YT0wO0Q9MTtHRD0xO1NDTD0tMQ==
X-Microsoft-Antispam-Message-Info:
=?utf-8?B?VjZIQkpKR05oRUo1Vzc0YTBDUW52S0lsYkJSMGRzY0hJMnRMOWdyRGowcGpk?=
=?utf-8?B?SUJLSDRPaStzakpJUHlaWVFnNWpBSGRsZ1Z4aEFmaXJOR1ZMUWxTTnQ1SXg1?=
=?utf-8?B?anhFNTJ5RGU2YjRiTWhWK3FvWXBJU29YSWdqM3VvUkZpY21aaW5lSkJ5WWph?=
=?utf-8?B?L2pxclptbVBGdm02emlHT3ZBQ1BHZTcrM0c3NmJ5alJLSGlaYVMvK0hwVmJV?=
=?utf-8?B?eHlTU2grSElBTVY5cXF2d250OXBmQ2pzeEVUWTlSZ1hCc1dEdStXMzFGcWlO?=
=?utf-8?B?VytUeEgyRWl5a2U1Y09VKyt3am9ZQVYrRm1LUkhRRGdKbkFTaHc4RTErQ1c0?=
=?utf-8?B?RjBNVllEVW9UakJIQm5FWWVYd2RuaENZTVJIUkI4RmlheWsyajZmanFCUlpt?=
=?utf-8?B?ZTJYZlg1RGxkbEVlRk0zallRWStiU1Z1QmJlTmtKS3J5MmZuOFk2blRHemEw?=
=?utf-8?B?OVhkUUhWWTAzV2dySnMra1pKMGo1Zy8xSFNuemx4Slg1ckhDcitmVGRHSDBW?=
=?utf-8?B?MFlOMDFtNmRPTDVSL3BGU0VNNWRObGVkUUlRcG9MSUJFeVBFcGtlVENSZmIr?=
=?utf-8?B?V3F6by8vOHBROWplTi9JdWtEVDFwUVZsdVk5djBtN0wzbk04RG56RjRsM1ZH?=
=?utf-8?B?cytsajBZNUNwUXk5SVRFZXhMejN3anYweGpCWkltQ2lwQnA3V1B6UUt0VUw1?=
=?utf-8?B?dXpLQ3hxemNQNWRGWmpqZi9BY2EzOTAwQ3h5RlF2RHQyVG1McWp6N1JXUWRY?=
=?utf-8?B?TjlCRWFmNFhQSitwSTk2cEhPK1N3ZVQxbktlMWFwa05hNGllOVpCc2Q3MUEy?=
=?utf-8?B?TlBHVE9YUE8xRUk3dndyNkFQVlhhN3JIMnUxL25pZ3JaM1hFS0VUOXNqT2NF?=
=?utf-8?B?Y3lFcUM0dDVuOGhTdmJ1RjJJK2sxZGViOUU2SE1DTUZ1c0pSSlNsazdPWHJ5?=
=?utf-8?B?TXo0dUUrZEhqaVpGTHNTUnNUTUl2L2hZeFhoNUVtcmJPQ0lXYnV5Yy8rSXBq?=
=?utf-8?B?bjYwVlBET0ErZkQ4KzJsQmM5b0hUTXJSSWlhdXlNeTZ2a0xlaHp5ZTZRQnox?=
=?utf-8?B?T2h2NkZKNmpLcDg4TCs5ckdoU3d5aEc1Q1FYUFdTOXhxcFJsaTdtZkVuNG1W?=
=?utf-8?B?SkVsN2llT3FpTnB6Q3lMbDR4ZzVzblhLVWw3VkpJblRQQVA4cDd1aGdtbll4?=
=?utf-8?B?U2RWQXplZjRreWhJRnQwWGhWT2pnVmxwTW9hdUxwRE9VaTJqd1lqenh3T2pK?=
=?utf-8?B?R2ZMaDJmNm1lS25TNU56ODFBcnc1TUZQbi9pZ0hnampKNUl0MzVQRG5wenZH?=
=?utf-8?B?dTdrcTA4VXUwZmdNaXBKMnVsY1phOEtLUEZWMzNnUlVxYXhrRDFUN3FFN0lZ?=
=?utf-8?B?MnVzbmhVQ2kvQVkzZ3NBQnNGL0NCNlZTbmV5ZW9FVWg5dUJTbmtaQnNZemRT?=
=?utf-8?B?cDFKUnRPU2VpNnNwM3V5eXJxMy9YbFhPYTRFSkEyTUZjSVlNaFV0UE5RbjhK?=
=?utf-8?B?NjJmckpva2xuaGhYT2Jkb2g1U1NEaFJmQWc5bVhheGZYMXY1b2toaVRPOXNT?=
=?utf-8?B?Y2ZhVjYyY0pnbmw4N3VneVR6bXFoRTlndE9lTzlac0JTRWFKc1BMTmNrNFMx?=
=?utf-8?B?M0lwTXI3STZXcFNmbytNcFB2VzJFSFpLSWFpbjlzcVlVRHk3RTFIUUQzOUlB?=
=?utf-8?B?YnR1eC9jUnVNWlhadktVKzM5MmdmR1pBTXVxK2xzUXZ4MzNUWW5rQXZ4SXMv?=
=?utf-8?B?RnBLUmcwT3FUWENucWtuTWhBQnl5VWFpczNGUnBkQ0ltM2ttMDM1RnFScXFa?=
=?utf-8?B?dEtNNnF4Q1FDS2RqRTRuRkNRUC9JVTdZZ216c3hycC9ZalptbDZNZ25ydWFp?=
=?utf-8?B?Z25qMGFLK1FQYm0vUU40OSt1SVJBTmdPTVNRN2JTVmxLTlRJMkZDeldKYWNx?=
=?utf-8?B?VEJEVHE5ZE9QNWsxZkxrb0pFOEU5cUJvT3ArOUFDMXlZM2N4Smk5ay9qQXEv?=
=?utf-8?B?ZXc3ZjVHMjdkcjBkN1Rodmdyd1JldkFBeDlVblRVbkxrY0xhZkIwVzBpTlNM?=
=?utf-8?B?THAvZ01hS3NVK0dHblFFQ0h6VXYydW1QaUwzM29zcjRYRFJRTU9NZWYxQ2Nw?=
=?utf-8?B?N1liQ3g2ZUtveTdTaW1ZSGovLzNWbWh2bDd6ZXRUR3B3eEYwakVCOS95aEs0?=
=?utf-8?B?NkkzL1dQREVlVHFXWmE4RktDUHFENVQwYW9YWE9LS2hrMzAyVWFXTDZFVkx5?=
=?utf-8?B?cU1nZDkzOTR1dk40SHFIcHRDSVRPajMvSVAyd0JQNDJnaVoxNmhNOFEzdzlj?=
=?utf-8?B?ODdUNXRIVkQvTHYzMytWY2o3UHZkdUNTR1pvSVJvclVCN01EZW5pVXdRUDgx?=
=?utf-8?B?Vmg2aUdlOUJzdXlPdXFlL01raHZSbkRONncyRlFLcGpLUFR4bm9BQXVJMHJC?=
=?utf-8?B?cWdJSFJwZEVkZjZkOTJqZG1FNHdZRWpGdUR6R2hjdHRoMTg1Z2lpeGpnZzlH?=
=?utf-8?B?Um5WOEJINFBFM3Evdmt4VVRCQnAwd2xBRGVralpwRnV0eUhJNTluQzFLQXI2?=
=?utf-8?B?NXI4amV3c0ZRZEZLRjE1ZEQ3aW90Y1I0K3NPN3ZoVyt1UVdzWUpQUGh1b25N?=
=?utf-8?Q?amuRKzTLQzIrlx9Vmv+SjIosxogY=3D?=
MIME-Version: 1.0
------=_Part_1041327705_575167926.1652070638478
Content-Type: multipart/related;
boundary="----=_Part_1041327706_445426653.1652070638478"
------=_Part_1041327706_445426653.1652070638478
Content-Type: text/html;charset=UTF-8
Content-Transfer-Encoding: quoted-printable
Here goes the original email content
------=_Part_1041327706_445426653.1652070638478--
------=_Part_1041327705_575167926.1652070638478--
--31A9507D-D0B3-4DCD-AFBB-413468892CFE--

View File

@ -0,0 +1,25 @@
Content-Type: multipart/alternative; boundary="===============5006593052976639648=="
MIME-Version: 1.0
Subject: My subject
From: foo@example.org
To: bar@example.net
--===============5006593052976639648==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
This is HTML
--===============5006593052976639648==
Content-Type: text/html; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
<html>
<body>
This is <i>HTML</i>
</body>
</html>
--===============5006593052976639648==--

View File

@ -0,0 +1,19 @@
X-SimpleLogin-Client-IP: 54.39.200.130
Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130;
helo=relay.somewhere.net; envelope-from=everwaste@gmail.com;
receiver=<UNKNOWN>
Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
(No client certificate requested)
by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069
for <wehrman_mannequin@sldev.ovh>; Thu, 17 Mar 2022 16:50:20 +0000 (UTC)
Date: Thu, 17 Mar 2022 16:50:18 +0000
To: {{ alias_email }}
From: somewhere@rainbow.com
Subject: test Thu, 17 Mar 2022 16:50:18 +0000
Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6>
X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/
X-Rspamd-Queue-Id: 6D8C13F069
X-Rspamd-Server: staging1
This is a test mailing

View File

@ -0,0 +1,63 @@
Received: by mail-ed1-f49.google.com with SMTP id ej4so13657316edb.7
for <gmail@simplemail.fplante.fr>; Mon, 27 Jun 2022 08:48:15 -0700 (PDT)
X-Gm-Message-State: AJIora8exR9DGeRFoKAtjzwLtUpH5hqx6Zt3tm8n4gUQQivGQ3fELjUV
yT7RQIfeW9Kv2atuOcgtmGYVU4iQ8VBeLmK1xvOYL4XpXfrT7ZrJNQ==
Authentication-Results: mx.google.com;
dkim=pass header.i=@matera.eu header.s=fnt header.b=XahYMey7;
dkim=pass header.i=@sendgrid.info header.s=smtpapi header.b="QOCS/yjt";
spf=pass (google.com: domain of bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu designates 168.245.4.42 as permitted sender) smtp.mailfrom="bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu";
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=matera.eu
Received: from out.frontapp.com (unknown)
by geopod-ismtpd-3-0 (SG)
with ESMTP id d2gM2N7PT7W8d2-UEC4ESA
for <csyndic.quartz@gmail.com>;
Mon, 27 Jun 2022 15:48:11.014 +0000 (UTC)
Content-Type: multipart/alternative;
boundary="----sinikael-?=_1-16563448907660.10629093370416887"
In-Reply-To:
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
References:
<imported@frontapp.com_t:AssembléeGénérale2022-06-25T16:32:03+02:006b3cdade-982b-47cd-8114-6a037dfb7d60>
<imported@frontapp.com_f924cce139940c9935621f067d46443597394f34>
<imported@frontapp.com_t:Appeldefonds2022-06-26T10:04:55+02:00d89f5e23-6d98-4f01-95fa-b7c7544b7aa9>
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
<af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
To: {{ alias_email }}
Subject: Something
Message-ID: <af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
X-Mailer: Front (1.0; +https://frontapp.com;
+msgid=af07e94a66ece6564ae30a2aaac7a34c@frontapp.com)
X-Feedback-ID: 14445963:SG
X-SG-EID:
=?us-ascii?Q?XtlxQDg5i3HqMzQY2Upg19JPZBVl1RybInUUL2yta9uBoIU4KU1FMJ5DjWrz6g?=
=?us-ascii?Q?fJUK5Qmneg2uc46gwp5BdHdp6Foaq5gg3xJriv3?=
=?us-ascii?Q?9OA=2FWRifeylU9O+ngdNbOKXoeJAkROmp2mCgw9x?=
=?us-ascii?Q?uud+EclOT9mYVtbZsydOLLm6Y2PPswQl8lnmiku?=
=?us-ascii?Q?DAhkG15HTz2FbWGWNDFb7VrSsN5ddjAscr6sIHw?=
=?us-ascii?Q?S48R5fnXmfhPbmlCgqFjr0FGphfuBdNAt6z6w8a?=
=?us-ascii?Q?o9u1EYDIX7zWHZ+Tr3eyw=3D=3D?=
X-SG-ID:
=?us-ascii?Q?N2C25iY2uzGMFz6rgvQsb8raWjw0ZPf1VmjsCkspi=2FI9PhcvqXQTpKqqyZkvBe?=
=?us-ascii?Q?+2RscnQ4WPkA+BN1vYgz1rezTVIqgp+rlWrKk8o?=
=?us-ascii?Q?HoB5dzpX6HKWtWCVRi10zwlDN1+pJnySoIUrlaT?=
=?us-ascii?Q?PA2aqQKmMQbjTl0CUAFryR8hhHcxdS0cQowZSd7?=
=?us-ascii?Q?XNjJWLvCGF7ODwg=2FKr+4yRE8UvULS2nrdO2wWyQ?=
=?us-ascii?Q?AiFHdPdZsRlgNomEo=3D?=
X-Spamd-Result: default: False [-2.00 / 13.00];
ARC_ALLOW(-1.00)[google.com:s=arc-20160816:i=1];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
REPLYTO_ADDR_EQ_FROM(0.00)[];
FORGED_RECIPIENTS_FORWARDING(0.00)[];
NEURAL_HAM(-0.00)[-0.981];
FREEMAIL_TO(0.00)[gmail.com];
RCVD_TLS_LAST(0.00)[];
FREEMAIL_ENVFROM(0.00)[gmail.com];
MIME_TRACE(0.00)[0:+,1:+,2:~];
RWL_MAILSPIKE_POSSIBLE(0.00)[209.85.208.49:from]
------sinikael-?=_1-16563448907660.10629093370416887
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Hello
------sinikael-?=_1-16563448907660.10629093370416887--

View File

@ -0,0 +1,64 @@
Received: by mail-ed1-f49.google.com with SMTP id ej4so13657316edb.7
for <gmail@simplemail.fplante.fr>; Mon, 27 Jun 2022 08:48:15 -0700 (PDT)
X-Gm-Message-State: AJIora8exR9DGeRFoKAtjzwLtUpH5hqx6Zt3tm8n4gUQQivGQ3fELjUV
yT7RQIfeW9Kv2atuOcgtmGYVU4iQ8VBeLmK1xvOYL4XpXfrT7ZrJNQ==
Authentication-Results: mx.google.com;
dkim=pass header.i=@matera.eu header.s=fnt header.b=XahYMey7;
dkim=pass header.i=@sendgrid.info header.s=smtpapi header.b="QOCS/yjt";
spf=pass (google.com: domain of bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu designates 168.245.4.42 as permitted sender) smtp.mailfrom="bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu";
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=matera.eu
Received: from out.frontapp.com (unknown)
by geopod-ismtpd-3-0 (SG)
with ESMTP id d2gM2N7PT7W8d2-UEC4ESA
for <csyndic.quartz@gmail.com>;
Mon, 27 Jun 2022 15:48:11.014 +0000 (UTC)
Content-Type: multipart/alternative;
boundary="----sinikael-?=_1-16563448907660.10629093370416887"
In-Reply-To:
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
References:
<imported@frontapp.com_t:AssembléeGénérale2022-06-25T16:32:03+02:006b3cdade-982b-47cd-8114-6a037dfb7d60>
<imported@frontapp.com_f924cce139940c9935621f067d46443597394f34>
<imported@frontapp.com_t:Appeldefonds2022-06-26T10:04:55+02:00d89f5e23-6d98-4f01-95fa-b7c7544b7aa9>
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
<af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
To: {{ contact_reply_email }}
Subject: Something
Message-ID: <af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
X-Mailer: Front (1.0; +https://frontapp.com;
+msgid=af07e94a66ece6564ae30a2aaac7a34c@frontapp.com)
X-Feedback-ID: 14445963:SG
X-SG-EID:
=?us-ascii?Q?XtlxQDg5i3HqMzQY2Upg19JPZBVl1RybInUUL2yta9uBoIU4KU1FMJ5DjWrz6g?=
=?us-ascii?Q?fJUK5Qmneg2uc46gwp5BdHdp6Foaq5gg3xJriv3?=
=?us-ascii?Q?9OA=2FWRifeylU9O+ngdNbOKXoeJAkROmp2mCgw9x?=
=?us-ascii?Q?uud+EclOT9mYVtbZsydOLLm6Y2PPswQl8lnmiku?=
=?us-ascii?Q?DAhkG15HTz2FbWGWNDFb7VrSsN5ddjAscr6sIHw?=
=?us-ascii?Q?S48R5fnXmfhPbmlCgqFjr0FGphfuBdNAt6z6w8a?=
=?us-ascii?Q?o9u1EYDIX7zWHZ+Tr3eyw=3D=3D?=
X-SG-ID:
=?us-ascii?Q?N2C25iY2uzGMFz6rgvQsb8raWjw0ZPf1VmjsCkspi=2FI9PhcvqXQTpKqqyZkvBe?=
=?us-ascii?Q?+2RscnQ4WPkA+BN1vYgz1rezTVIqgp+rlWrKk8o?=
=?us-ascii?Q?HoB5dzpX6HKWtWCVRi10zwlDN1+pJnySoIUrlaT?=
=?us-ascii?Q?PA2aqQKmMQbjTl0CUAFryR8hhHcxdS0cQowZSd7?=
=?us-ascii?Q?XNjJWLvCGF7ODwg=2FKr+4yRE8UvULS2nrdO2wWyQ?=
=?us-ascii?Q?AiFHdPdZsRlgNomEo=3D?=
X-Spamd-Result: default: False [-2.00 / 13.00];
ARC_ALLOW(-1.00)[google.com:s=arc-20160816:i=1];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
REPLYTO_ADDR_EQ_FROM(0.00)[];
FORGED_RECIPIENTS_FORWARDING(0.00)[];
NEURAL_HAM(-0.00)[-0.981];
FREEMAIL_TO(0.00)[gmail.com];
RCVD_TLS_LAST(0.00)[];
FREEMAIL_ENVFROM(0.00)[gmail.com];
MIME_TRACE(0.00)[0:+,1:+,2:~];
RWL_MAILSPIKE_POSSIBLE(0.00)[209.85.208.49:from]
------sinikael-?=_1-16563448907660.10629093370416887
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
Contact is {{ contact_reply_email }}
Other contact is {{ other_contact_reply_email }}
------sinikael-?=_1-16563448907660.10629093370416887--

View File

@ -0,0 +1,157 @@
X-SimpleLogin-Client-IP: 66.163.186.21
Received-SPF: None (mailfrom) identity=mailfrom; client-ip=66.163.186.21;
helo=sonic326-46.consmr.mail.ne1.yahoo.com;
envelope-from=feedback@arf.mail.yahoo.com; receiver=<UNKNOWN>
Received: from sonic326-46.consmr.mail.ne1.yahoo.com
(sonic326-46.consmr.mail.ne1.yahoo.com [66.163.186.21])
(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits)
key-exchange ECDHE (P-256) server-signature RSA-PSS (2048 bits)
server-digest SHA256)
(No client certificate requested)
by prod4.simplelogin.co (Postfix) with ESMTPS id 160E19C47C
for <{{ postmaster }}>; Sun, 8 May 2022 13:31:32 +0000 (UTC)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=arf.mail.yahoo.com;
s=arf; t=1652016690; bh=y3TXlG8d2nUmz+Mm6gBEX1p1y2rwlM+LRC89Bp+HwGo=;
h=Date:From:To:Subject:From:Subject:Reply-To;
b=HyuY58LSzfkdH9FynjNWEl6QJeeImKRbIzrnR64sY/ggFD6fF9w1/fpXDmJ8RHpB/72llGb8nkVJkn/TK+adBCZvw4Y0SC2m8qbn6BdaC5kvAWkN6VUxvQWFMWTptAmeX+UUxY2hjEXLZQwNUd4nvvhZkbdyzw5wFSpYX0hnxAA=
X-SONIC-DKIM-SIGN: v=1; a=rsa-sha256; c=relaxed/relaxed; d=yahoo.com; s=s2048;
t=1652016690; bh=0SlXAOx+1D8SxkBJpASrTwUGjphtzchFZOSJr0X+U2m=;
h=X-Sonic-MF:Date:From:To:Subject:From:Subject;
b=smqcDrz5jxsmGycWk9tNncLBjcQIqBnZmsQzkJ6g8fyhQw2e30y05iTnsOBTr0S9qTPK3I2JBv0P73TH7vDAnZAnaewzj9Dymw7Z+UxXKdrPBf/tD8RGw9cX6C0eb7GUjHvbvXS03IkSGnvOPPCXLsTDXYOTflcU7A0A2L+cS9ogEBl/4AFwBf/z+lcMH20h2dZ6+wPtqPCgRY1Hf45cv4gfHrFG0a18n3BBq0doCA4cRTXeeuv06fqsUCk2GF6z0mm3YWu+umcUs16QmgjHKhy4SJHvTZfx4zFBxQEOM3hvBzriL5g0D3Rg71CdkI8TVqsyXS1YWVSQFakAw0hM+A==
X-Sonic-MF: feedback@arf.mail.yahoo.com
Received: from sonic.gate.mail.ne1.yahoo.com by
sonic326.consmr.mail.ne1.yahoo.com with HTTP; Sun, 8 May 2022 13:31:30 +0000
Date: Sun, 8 May 2022 13:31:28 +0000 (UTC)
From: Yahoo! Mail AntiSpam Feedback <feedback@arf.mail.yahoo.com>
To: {{ postmaster }}
Message-ID:
<1486688083.18136997.1652016688605@chakraconsumer2.asd.mail.ne1.yahoo.com>
Subject: Original subject
MIME-Version: 1.0
Content-Type: multipart/report; report-type=feedback-report;
boundary="----=_Part_18136996_1734597748.1652016688604"
X-Yahoo-Newman-Property: cfl
X-Yahoo-Newman-Id: cfl-test
X-Spamd-Result: default: False [-0.65 / 13.00];
DMARC_POLICY_ALLOW(-0.50)[yahoo.com,reject];
R_DKIM_ALLOW(-0.20)[arf.mail.yahoo.com:s=arf];
SUBJ_ALL_CAPS(0.15)[2];
MIME_GOOD(-0.10)[text/plain,multipart/alternative];
R_SPF_NA(0.00)[no SPF record];
FROM_EQ_ENVFROM(0.00)[];
MIME_TRACE(0.00)[0:~,1:+,2:~,3:+,4:~,5:+,6:+,7:~];
RCVD_TLS_LAST(0.00)[];
RCVD_IN_DNSWL_NONE(0.00)[66.163.186.21:from];
ASN(0.00)[asn:36646, ipnet:66.163.184.0/21, country:US];
ARC_NA(0.00)[];
DKIM_TRACE(0.00)[arf.mail.yahoo.com:+];
MID_RHS_MATCH_FROMTLD(0.00)[];
TO_MATCH_ENVRCPT_ALL(0.00)[];
FROM_HAS_DN(0.00)[];
RCVD_COUNT_TWO(0.00)[2];
TO_DN_NONE(0.00)[];
RCPT_COUNT_ONE(0.00)[1];
NEURAL_SPAM(0.00)[0.429];
DWL_DNSWL_NONE(0.00)[yahoo.com:dkim]
X-Rspamd-Queue-Id: 160E19C47C
X-Rspamd-Server: prod4
Content-Transfer-Encoding: 7bit
------=_Part_18136996_1734597748.1652016688604
Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Content-Disposition: inline
This is an email abuse report for an email message from simplelogin.co on Sun, 8 May 2022 11:12:35 +0000
------=_Part_18136996_1734597748.1652016688604
Content-Type: message/feedback-report
Content-Transfer-Encoding: 7bit
Content-Disposition: inline
Feedback-Type: abuse
User-Agent: Yahoo!-Mail-Feedback/2.0
Version: 0.1
Original-Mail-From:
<{{ return_path }}>
Original-Rcpt-To: {{ rcpt }}
Received-Date: Sun, 8 May 2022 11:12:35 +0000
Reported-Domain: simplelogin.co
Authentication-Results: authentication result string is not available
------=_Part_18136996_1734597748.1652016688604
Content-Type: message/rfc822
Content-Disposition: inline
Received: from 10.217.151.74
by atlas316.free.mail.ne1.yahoo.com with HTTPS;
Sun, 8 May 2022 11:12:34 +0000
Return-Path:
<{{ return_path }}>
X-Originating-Ip: [176.129.238.160]
Received-SPF: pass (domain of simplelogin.co designates 176.119.200.160 as
permitted sender)
Authentication-Results: atlas316.free.mail.ne1.yahoo.com;
dkim=pass header.i=@simplelogin.co header.s=dkim;
spf=pass smtp.mailfrom=simplelogin.co;
dmarc=pass(p=QUARANTINE) header.from=simplelogin.co;
X-Apparently-To: syn_flood91@yahoo.com; Sun, 8 May 2022 11:12:35 +0000
X-YMailISG: 5XbMksQWLDvXV9CBjagtqIT6OTC44ku5XiuZJQp_W6hhWfR.
.wUIhFV6vRR_JeMUxC0ZAvugteAP2pe.bqk06ovvYnhJMg_HTvcmfVltbWxQ
tK7xNSs8D2PWQdyDDzB3rdFdIIfSrQnDTGjP2xpTAqLQk3IXSuUBX7s4f8uA
WUELPWj36_Xtqrwyj.ya4Ezw_ePzPhZGmMdCsbz2H5Jh45TLbk5HhL.TDDbH
9Dz__HKLUC8acH0hu1vrPvo1ljzwbl_0cqlj10qMIChpB51XVDtyNA_WgWvE
QL1hFHS0tScfRT0xATM8w8FJv1eA0ODjakDtTRgmaWBTphzeoR.FyTBj14y5
burx6lkUqipfP7UZpNmcNDYHQdTEmdGa8JDZMX.lpM5IMOhkByIQuoTN4.Cx
8qz9kb.o0DqxqNRgn4_fRRAoSn1xejDbzZMu.SWSvJ1KJwAfLtep37ISqNKl
yeBeDJFMnHUjRD8B2wBB46zq4ngHFWjBGkAGQVBssLzj594FXg13aO.TnJU7
WJ_cUSzoaH9HjgYDTi4.1x68jVxpZIEdhDe7pjLCUL2ugWdar9S7pFlyKWfa
iTH8yQ10NXtLCwGpJ.0kgZH2WXJgyJmrq0a3j63skib7WJYtKOXfsbHV8b9e
WxClOETCe03PtdD6G2sjEJSNFyTH_Qzzq6_21PO6kjmnEnBbibAnkiJbGhIJ
kOSqyp_vFqstpd38vtt7iLI8L3PkyZDQXS0hB1ZCOsZqBDGJXAoWFRBtxMSd
rMVkdvB6r8xJtn.1JrV1hpX4yRbCuEnCCPcwtGamlpyq5LG6YanKUVB868KF
UuZ4AHFwi.m_FYHalwtfCaArtWzYybl2nQQLjPbnXxqNvfwKt3ATKFEO40ZV
w1Ri7y.cO__09.eQHKIUNgMNeWgt.luD3thsEl0yz_ThzrCEkXDB1xAPNnLV
tb03RulEB0xNauYTuWgKR8WJzkO4LuXMlzNAAYBQLQy_t0GoezAs7Z4oq.CH
EfTK88cDJ7j7dXcXBi7q6g1NBZT3tyd9Bfn2DVdFaWAjWV9Lb8tir6J43MDP
byTrZ_zJxTWKgafhOxL0gZbd5xIEZ1eHHeQO5pVZlN6FR1awozFgS4NcZu5u
5qRtn6zHo3zNe9ORwwxqlHAEJR_5I09WYSdmTxh2QkkDQLjSlwUNV4K8jxdH
L4ePIzNCQCt_bsGoG3uPXl8jtPD4sUWGY1lCeKAm.AHgZ.pSXXypMUpq4y14
NihY89H61y5ZXo4Zd77shda_
Received: from 176.119.200.160 (EHLO mail-200160.simplelogin.co)
by 10.217.151.74 with SMTPs
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256);
Sun, 08 May 2022 11:12:34 +0000
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=simplelogin.co;
s=dkim; t=1652008349; h=From:To:Subject:Message-ID:Date;
bh=9HnrBUpZUe8OSXqTw1qF667IwLtHI8DqiyD0yAovIO4=;
b=PsxiMydvEQveb20xgUvvq3DhxlLyqqoPW7sC8d/pAm8tj7T2O+7z5xxR6vVbgz823Bglzc
djb3pRvNLgHnTozC+FiFOF8nVlWGybosn5oRfmNGkF9bhr0bJmfcDhiuC/tOaZKkod2lbf
jQ8bqMZhCsN/xVpkMqJdNJefdkj3dP4=
MIME-Version: 1.0
Date: Sun, 8 May 2022 04:11:42 -0700
Message-ID:
<CAKGh96GHg2kuwvm4biQ-PF-4-8SPZ6JyPj-=GpoYZ6njctoRtg@mail.gmail.com>
Subject: MF
Content-Type: multipart/alternative; boundary="0000000000006dd95f05de7e2a70"
Content-Transfer-Encoding: 7bit
X-SimpleLogin-Type: Forward
X-SimpleLogin-EmailLog-ID: 41263490
X-SimpleLogin-Envelope-From: {{ sender }}
X-SimpleLogin-Envelope-To: {{ rcpt }}
From: {{ sender }}
To: {{ rcpt_comma_list }}
List-Unsubscribe: <mailto:unsubscribe@simplelogin.co?subject=1231546=>
X-SimpleLogin-Want-Signing: yes
Content-Length: 473
--0000000000006dd95f05de7e2a70
Content-Type: text/plain; charset="UTF-8"
Here goes the original email content
--0000000000006dd95f05de7e2a70--
------=_Part_18136996_1734597748.1652016688604--

View File

View File

@ -0,0 +1,111 @@
import random
from email.message import Message
import pytest
from app.config import (
ALERT_COMPLAINT_FORWARD_PHASE,
ALERT_COMPLAINT_REPLY_PHASE,
ALERT_COMPLAINT_TRANSACTIONAL_PHASE,
POSTMASTER,
)
from app.db import Session
from app.email_utils import generate_verp_email
from app.handler.provider_complaint import (
handle_hotmail_complaint,
handle_yahoo_complaint,
)
from app.mail_sender import mail_sender
from app.models import (
Alias,
ProviderComplaint,
SentAlert,
EmailLog,
VerpType,
Contact,
)
from tests.utils import create_new_user, load_eml_file
origins = [
[handle_yahoo_complaint, "yahoo"],
[handle_hotmail_complaint, "hotmail"],
]
def prepare_complaint(
provider_name: str, alias: Alias, rcpt_address: str, sender_address: str
) -> Message:
contact = Contact.create(
user_id=alias.user.id,
alias_id=alias.id,
website_email=f"contact{random.random()}@mailbox.test",
reply_email="d@e.f",
commit=True,
)
elog = EmailLog.create(
user_id=alias.user.id,
mailbox_id=alias.user.default_mailbox_id,
contact_id=contact.id,
commit=True,
bounced=True,
)
return_path = generate_verp_email(VerpType.bounce_forward, elog.id)
return load_eml_file(
f"{provider_name}_complaint.eml",
{
"postmaster": POSTMASTER,
"return_path": return_path,
"rcpt": rcpt_address,
"sender": sender_address,
"rcpt_comma_list": f"{rcpt_address},other_rcpt@somwhere.net",
},
)
@mail_sender.store_emails_test_decorator
@pytest.mark.parametrize("handle_ftor,provider", origins)
def test_provider_to_user(flask_client, handle_ftor, provider):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
complaint = prepare_complaint(provider, alias, user.email, "nobody@nowhere.net")
assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 0
alerts = SentAlert.filter_by(user_id=user.id).all()
assert len(alerts) == 1
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
assert alerts[0].alert_type == f"{ALERT_COMPLAINT_TRANSACTIONAL_PHASE}_{provider}"
@pytest.mark.parametrize("handle_ftor,provider", origins)
def test_provider_forward_phase(flask_client, handle_ftor, provider):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
complaint = prepare_complaint(provider, alias, "nobody@nowhere.net", alias.email)
assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 1
alerts = SentAlert.filter_by(user_id=user.id).all()
assert len(alerts) == 1
assert alerts[0].alert_type == f"{ALERT_COMPLAINT_REPLY_PHASE}_{provider}"
@mail_sender.store_emails_test_decorator
@pytest.mark.parametrize("handle_ftor,provider", origins)
def test_provider_reply_phase(flask_client, handle_ftor, provider):
mail_sender.store_emails_instead_of_sending()
mail_sender.purge_stored_emails()
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
complaint = prepare_complaint(provider, alias, alias.email, "no@no.no")
assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 0
alerts = SentAlert.filter_by(user_id=user.id).all()
assert len(alerts) == 1
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
assert alerts[0].alert_type == f"{ALERT_COMPLAINT_FORWARD_PHASE}_{provider}"

View File

@ -0,0 +1,46 @@
from app.handler.spamd_result import DmarcCheckResult, SpamdResult
from tests.utils import load_eml_file
def test_dmarc_result_softfail():
msg = load_eml_file("dmarc_gmail_softfail.eml")
assert DmarcCheckResult.soft_fail == SpamdResult.extract_from_headers(msg).dmarc
assert SpamdResult.extract_from_headers(msg).rspamd_score == 0.5
def test_dmarc_result_quarantine():
msg = load_eml_file("dmarc_quarantine.eml")
assert DmarcCheckResult.quarantine == SpamdResult.extract_from_headers(msg).dmarc
def test_dmarc_result_reject():
msg = load_eml_file("dmarc_reject.eml")
assert DmarcCheckResult.reject == SpamdResult.extract_from_headers(msg).dmarc
def test_dmarc_result_allow():
msg = load_eml_file("dmarc_allow.eml")
assert DmarcCheckResult.allow == SpamdResult.extract_from_headers(msg).dmarc
def test_dmarc_result_na():
msg = load_eml_file("dmarc_na.eml")
assert DmarcCheckResult.not_available == SpamdResult.extract_from_headers(msg).dmarc
def test_dmarc_result_bad_policy():
msg = load_eml_file("dmarc_bad_policy.eml")
assert SpamdResult._get_from_message(msg) is None
assert DmarcCheckResult.bad_policy == SpamdResult.extract_from_headers(msg).dmarc
assert SpamdResult._get_from_message(msg) is not None
def test_parse_rspamd_score():
msg = load_eml_file("dmarc_gmail_softfail.eml")
assert SpamdResult.extract_from_headers(msg).rspamd_score == 0.5
def test_cannot_parse_rspamd_score():
msg = load_eml_file("dmarc_cannot_parse_rspamd_score.eml")
# use the default score when cannot parse
assert SpamdResult.extract_from_headers(msg).rspamd_score == -1

View File

@ -0,0 +1,116 @@
import pytest
from app import config
from app.handler.unsubscribe_encoder import (
UnsubscribeData,
UnsubscribeAction,
UnsubscribeEncoder,
UnsubscribeOriginalData,
)
legacy_subject_test_data = [
("3=", UnsubscribeData(UnsubscribeAction.DisableAlias, 3)),
("438_", UnsubscribeData(UnsubscribeAction.DisableContact, 438)),
("4325*", UnsubscribeData(UnsubscribeAction.UnsubscribeNewsletter, 4325)),
]
@pytest.mark.parametrize("expected_subject, expected_deco", legacy_subject_test_data)
def test_legacy_unsub_subject(expected_subject, expected_deco):
info = UnsubscribeEncoder.decode_subject(expected_subject)
assert info == expected_deco
legacy_url_test_data = [
(
f"{config.URL}/dashboard/unsubscribe/3",
UnsubscribeData(UnsubscribeAction.DisableAlias, 3),
),
(
f"{config.URL}/dashboard/block_contact/5",
UnsubscribeData(UnsubscribeAction.DisableContact, 5),
),
]
@pytest.mark.parametrize("expected_url, unsub_data", legacy_url_test_data)
def test_encode_decode_unsub_subject(expected_url, unsub_data):
url = UnsubscribeEncoder.encode_url(unsub_data.action, unsub_data.data)
assert expected_url == url
legacy_mail_or_link_test_data = [
(
f"{config.URL}/dashboard/unsubscribe/3",
False,
UnsubscribeData(UnsubscribeAction.DisableAlias, 3),
),
(
"mailto:me@nowhere.net?subject=un.WzIsIDld.ONeJMiTW6CosJg4PMR1MPcDs-6GWoTOQFMfA2A",
True,
UnsubscribeData(UnsubscribeAction.DisableAlias, 9),
),
(
f"{config.URL}/dashboard/block_contact/8",
False,
UnsubscribeData(UnsubscribeAction.DisableContact, 8),
),
(
"mailto:me@nowhere.net?subject=un.WzMsIDhd.eo_Ynk0eNyPtsHXMpTqw7HMFgYmm1Up_wWUc3g",
True,
UnsubscribeData(UnsubscribeAction.DisableContact, 8),
),
(
"mailto:me@nowhere.net?subject=un.WzEsIDgzXQ.NZAWqfpCmLEszwc5nWuQwDSLJ3TXO3rcOe_73Q",
True,
UnsubscribeData(UnsubscribeAction.UnsubscribeNewsletter, 83),
),
(
f"{config.URL}/dashboard/unsubscribe/encoded?data=un.WzQsIFswLCAxLCAiYUBiLmMiLCAic3ViamVjdCJdXQ.aU3T5XNzJIG4LDm6-pqJk4vxxJxpgVYzc9MEFQ",
False,
UnsubscribeData(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(1, "a@b.c", "subject"),
),
),
(
"mailto:me@nowhere.net?subject=un.WzQsIFswLCAxLCAiYUBiLmMiLCAic3ViamVjdCJdXQ.aU3T5XNzJIG4LDm6-pqJk4vxxJxpgVYzc9MEFQ",
True,
UnsubscribeData(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(1, "a@b.c", "subject"),
),
),
]
@pytest.mark.parametrize(
"expected_link, via_mail, unsub_data", legacy_mail_or_link_test_data
)
def test_encode_legacy_link(expected_link, via_mail, unsub_data):
if via_mail:
config.UNSUBSCRIBER = "me@nowhere.net"
else:
config.UNSUBSCRIBER = None
link_info = UnsubscribeEncoder.encode(unsub_data.action, unsub_data.data)
assert via_mail == link_info.via_email
assert expected_link == link_info.link
encode_decode_test_data = [
UnsubscribeData(UnsubscribeAction.DisableContact, 3),
UnsubscribeData(UnsubscribeAction.DisableContact, 10),
UnsubscribeData(UnsubscribeAction.DisableAlias, 101),
UnsubscribeData(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(323, "a@b.com", "some subject goes here"),
),
]
@pytest.mark.parametrize("unsub_data", encode_decode_test_data)
def test_encode_decode_unsub(unsub_data):
encoded = UnsubscribeEncoder.encode_subject(unsub_data.action, unsub_data.data)
decoded = UnsubscribeEncoder.decode_subject(encoded)
assert unsub_data.action == decoded.action
assert unsub_data.data == decoded.data

View File

@ -0,0 +1,206 @@
from email.message import Message
from typing import Iterable
import pytest
from app import config
from app.db import Session
from app.email import headers
from app.handler.unsubscribe_encoder import (
UnsubscribeAction,
UnsubscribeEncoder,
UnsubscribeOriginalData,
)
from app.handler.unsubscribe_generator import UnsubscribeGenerator
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
from tests.utils import create_new_user
TEST_UNSUB_EMAIL = "unsub@sl.com"
def generate_unsub_block_contact_data() -> Iterable:
user = create_new_user()
user.unsub_behaviour = UnsubscribeBehaviourEnum.BlockContact
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
subject = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.DisableContact, contact.id
)
yield (
alias.id,
contact.id,
True,
"<https://lol.com>, <mailto:somewhere@not.net>",
f"<mailto:{TEST_UNSUB_EMAIL}?subject={subject}>",
)
yield (
alias.id,
contact.id,
False,
"<https://lol.com>, <mailto:somewhere@not.net>",
f"<{config.URL}/dashboard/block_contact/{contact.id}>",
)
yield (
alias.id,
contact.id,
False,
None,
f"<{config.URL}/dashboard/block_contact/{contact.id}>",
)
@pytest.mark.parametrize(
"alias_id, contact_id, unsub_via_mail, original_header, expected_header",
generate_unsub_block_contact_data(),
)
def test_unsub_disable_contact(
alias_id, contact_id, unsub_via_mail, original_header, expected_header
):
alias = Alias.get(alias_id)
contact = Contact.get(contact_id)
config.UNSUBSCRIBER = TEST_UNSUB_EMAIL if unsub_via_mail else None
message = Message()
message[headers.LIST_UNSUBSCRIBE] = original_header
message = UnsubscribeGenerator().add_header_to_message(alias, contact, message)
assert expected_header == message[headers.LIST_UNSUBSCRIBE]
if not expected_header or expected_header.find("<http") == -1:
assert message[headers.LIST_UNSUBSCRIBE_POST] is None
else:
assert "List-Unsubscribe=One-Click" == message[headers.LIST_UNSUBSCRIBE_POST]
def generate_unsub_disable_alias_data() -> Iterable:
user = create_new_user()
user.unsub_behaviour = UnsubscribeBehaviourEnum.DisableAlias
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
subject = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.DisableAlias, alias.id
)
yield (
alias.id,
contact.id,
True,
"<https://lol.com>, <mailto:somewhere@not.net>",
f"<mailto:{TEST_UNSUB_EMAIL}?subject={subject}>",
)
yield (
alias.id,
contact.id,
False,
"<https://lol.com>, <mailto:somewhere@not.net>",
f"<{config.URL}/dashboard/unsubscribe/{alias.id}>",
)
yield (
alias.id,
contact.id,
False,
None,
f"<{config.URL}/dashboard/unsubscribe/{alias.id}>",
)
@pytest.mark.parametrize(
"alias_id, contact_id, unsub_via_mail, original_header, expected_header",
generate_unsub_disable_alias_data(),
)
def test_unsub_disable_alias(
alias_id, contact_id, unsub_via_mail, original_header, expected_header
):
alias = Alias.get(alias_id)
contact = Contact.get(contact_id)
config.UNSUBSCRIBER = TEST_UNSUB_EMAIL if unsub_via_mail else None
message = Message()
message[headers.LIST_UNSUBSCRIBE] = original_header
message = UnsubscribeGenerator().add_header_to_message(alias, contact, message)
assert expected_header == message[headers.LIST_UNSUBSCRIBE]
if not expected_header or expected_header.find("<http") == -1:
assert message[headers.LIST_UNSUBSCRIBE_POST] is None
else:
assert "List-Unsubscribe=One-Click" == message[headers.LIST_UNSUBSCRIBE_POST]
def generate_unsub_preserve_original_data() -> Iterable:
user = create_new_user()
user.unsub_behaviour = UnsubscribeBehaviourEnum.PreserveOriginal
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
yield (
alias.id,
contact.id,
True,
"<https://lol.com>, <mailto:somewhere@not.net>",
"<https://lol.com>",
)
yield (
alias.id,
contact.id,
False,
"<https://lol.com>, <mailto:somewhere@not.net>",
"<https://lol.com>",
)
unsub_data = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, "test@test.com", "hello"),
)
yield (
alias.id,
contact.id,
True,
"<mailto:test@test.com?subject=hello>",
f"<mailto:{TEST_UNSUB_EMAIL}?subject={unsub_data}>",
)
yield (
alias.id,
contact.id,
False,
"<mailto:test@test.com?subject=hello>",
f"<{config.URL}/dashboard/unsubscribe/encoded?data={unsub_data}>",
)
yield (alias.id, contact.id, True, None, None)
yield (alias.id, contact.id, False, None, None)
@pytest.mark.parametrize(
"alias_id, contact_id, unsub_via_mail, original_header, expected_header",
generate_unsub_preserve_original_data(),
)
def test_unsub_preserve_original(
alias_id, contact_id, unsub_via_mail, original_header, expected_header
):
alias = Alias.get(alias_id)
contact = Contact.get(contact_id)
config.UNSUBSCRIBER = TEST_UNSUB_EMAIL if unsub_via_mail else None
message = Message()
message[headers.LIST_UNSUBSCRIBE] = original_header
message = UnsubscribeGenerator().add_header_to_message(alias, contact, message)
assert expected_header == message[headers.LIST_UNSUBSCRIBE]
if not expected_header or expected_header.find("<http") == -1:
assert message[headers.LIST_UNSUBSCRIBE_POST] is None
else:
assert "List-Unsubscribe=One-Click" == message[headers.LIST_UNSUBSCRIBE_POST]

View File

@ -0,0 +1,231 @@
from email.message import Message
from random import random
from aiosmtpd.smtp import Envelope
from flask import url_for
from app.db import Session
from app.email import headers, status
from app.email_utils import parse_full_address
from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder,
UnsubscribeAction,
UnsubscribeOriginalData,
)
from app.handler.unsubscribe_handler import (
UnsubscribeHandler,
)
from app.mail_sender import mail_sender
from app.models import Alias, Contact, User
from tests.utils import create_new_user, login
def _get_envelope_and_message(user: User, subject: str) -> (Envelope, Message):
envelope = Envelope()
envelope.mail_from = user.email
message = Message()
message[headers.SUBJECT] = subject
return envelope, message
@mail_sender.store_emails_test_decorator
def test_old_subject_disable_alias():
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
envelope, message = _get_envelope_and_message(user, f"{alias.id}=")
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert not Alias.get(alias.id).enabled
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_old_subject_block_contact():
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email=f"{random()}@sl.local",
block_forward=False,
commit=True,
)
envelope, message = _get_envelope_and_message(user, f"{contact.id}_")
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert Contact.get(contact.id).block_forward
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_old_subject_disable_newsletter():
user = create_new_user()
envelope, message = _get_envelope_and_message(user, f"{user.id}*")
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert not User.get(user.id).notification
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_new_subject_disable_alias():
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
header = UnsubscribeEncoder.encode_subject(UnsubscribeAction.DisableAlias, alias.id)
envelope, message = _get_envelope_and_message(user, header)
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert not Alias.get(alias.id).enabled
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_new_subject_block_contact():
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email=f"{random()}@sl.local",
block_forward=False,
commit=True,
)
header = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.DisableContact, contact.id
)
envelope, message = _get_envelope_and_message(user, header)
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert Contact.get(contact.id).block_forward
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_new_subject_disable_newsletter():
user = create_new_user()
header = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.UnsubscribeNewsletter, user.id
)
envelope, message = _get_envelope_and_message(user, header)
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert not User.get(user.id).notification
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_new_subject_original_unsub():
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
envelope = Envelope()
envelope.mail_from = user.email
message = Message()
original_recipient = f"{random()}@out.com"
original_subject = f"Unsubsomehow{random()}"
message[headers.SUBJECT] = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, original_recipient, original_subject),
)
response = UnsubscribeHandler().handle_unsubscribe_from_message(envelope, message)
assert status.E202 == response
assert 1 == len(mail_sender.get_stored_emails())
mail_sent = mail_sender.get_stored_emails()[0]
assert mail_sent.envelope_to == original_recipient
name, address = parse_full_address(mail_sent.msg[headers.FROM])
assert name == ""
assert alias.email == address
assert mail_sent.msg[headers.TO] == original_recipient
assert mail_sent.msg[headers.SUBJECT] == original_subject
@mail_sender.store_emails_test_decorator
def test_request_disable_alias(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
req_data = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.DisableAlias, alias.id
)
req = flask_client.get(
url_for("dashboard.encoded_unsubscribe", encoded_request=req_data),
follow_redirects=True,
)
assert 200 == req.status_code
assert not Alias.get(alias.id).enabled
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_request_disable_contact(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email=f"{random()}@sl.local",
block_forward=False,
commit=True,
)
req_data = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.DisableContact, contact.id
)
req = flask_client.get(
url_for("dashboard.encoded_unsubscribe", encoded_request=req_data),
follow_redirects=True,
)
assert 200 == req.status_code
assert Contact.get(contact.id).block_forward
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_request_disable_newsletter(flask_client):
user = login(flask_client)
req_data = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.UnsubscribeNewsletter, user.id
)
req = flask_client.get(
url_for("dashboard.encoded_unsubscribe", encoded_request=req_data),
follow_redirects=True,
)
assert 200 == req.status_code
assert not User.get(user.id).notification
assert 1 == len(mail_sender.get_stored_emails())
@mail_sender.store_emails_test_decorator
def test_request_original_unsub(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
original_recipient = f"{random()}@out.com"
original_subject = f"Unsubsomehow{random()}"
mail_sender.purge_stored_emails()
req_data = UnsubscribeEncoder.encode_subject(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, original_recipient, original_subject),
)
req = flask_client.get(
url_for("dashboard.encoded_unsubscribe", encoded_request=req_data),
follow_redirects=True,
)
assert 200 == req.status_code
assert 1 == len(mail_sender.get_stored_emails())
mail_sent = mail_sender.get_stored_emails()[0]
assert mail_sent.envelope_to == original_recipient
name, address = parse_full_address(mail_sent.msg[headers.FROM])
assert name == ""
assert alias.email == address
assert mail_sent.msg[headers.TO] == original_recipient
assert mail_sent.msg[headers.SUBJECT] == original_subject

View File

View File

@ -0,0 +1,145 @@
import zipfile
from random import random
from app.db import Session
from app.jobs.export_user_data_job import ExportUserDataJob
from app.models import (
Contact,
Directory,
DirectoryMailbox,
RefusedEmail,
CustomDomain,
EmailLog,
Alias,
)
from tests.utils import create_new_user, random_token
def test_model_retrieval_and_serialization():
user = create_new_user()
job = ExportUserDataJob(user)
ExportUserDataJob._model_to_dict(user)
# Aliases
aliases = job._get_aliases()
assert len(aliases) == 1
ExportUserDataJob._model_to_dict(aliases[0])
# Mailboxes
mailboxes = job._get_mailboxes()
assert len(mailboxes) == 1
ExportUserDataJob._model_to_dict(mailboxes[0])
# Contacts
alias = aliases[0]
contact = Contact.create(
website_email=f"marketing-{random()}@example.com",
reply_email=f"reply-{random()}@a.b",
alias_id=alias.id,
user_id=alias.user_id,
commit=True,
)
contacts = job._get_contacts()
assert len(contacts) == 1
assert contact.id == contacts[0].id
ExportUserDataJob._model_to_dict(contacts[0])
# Directories
dir_name = random_token()
directory = Directory.create(name=dir_name, user_id=user.id, flush=True)
DirectoryMailbox.create(
directory_id=directory.id, mailbox_id=user.default_mailbox_id, flush=True
)
directories = job._get_directories()
assert len(directories) == 1
assert directory.id == directories[0].id
ExportUserDataJob._model_to_dict(directories[0])
# CustomDomain
custom_domain = CustomDomain.create(
domain=f"{random()}.com", user_id=user.id, commit=True
)
domains = job._get_domains()
assert len(domains) == 1
assert custom_domain.id == domains[0].id
ExportUserDataJob._model_to_dict(domains[0])
# RefusedEmails
refused_email = RefusedEmail.create(
path=None,
full_report_path=f"some/path/{random()}",
user_id=alias.user_id,
commit=True,
)
refused_emails = job._get_refused_emails()
assert len(refused_emails) == 1
assert refused_email.id == refused_emails[0].id
ExportUserDataJob._model_to_dict(refused_emails[0])
# EmailLog
email_log = EmailLog.create(
user_id=user.id,
refused_email_id=refused_email.id,
mailbox_id=alias.mailbox.id,
contact_id=contact.id,
alias_id=alias.id,
commit=True,
)
email_logs = job._get_email_logs()
assert len(email_logs) == 1
assert email_log.id == email_logs[0].id
ExportUserDataJob._model_to_dict(email_logs[0])
# Get zip
memfile = job._build_zip()
files_in_zip = set()
with zipfile.ZipFile(memfile, "r") as zf:
for file_info in zf.infolist():
files_in_zip.add(file_info.filename)
assert file_info.file_size > 0
expected_files_in_zip = set(
(
"user.json",
"aliases.json",
"mailboxes.json",
"contacts.json",
"directories.json",
"domains.json",
"email_logs.json",
# "refused_emails.json",
)
)
assert expected_files_in_zip == files_in_zip
def test_model_retrieval_pagination():
user = create_new_user()
aliases = Session.query(Alias).filter(Alias.user_id == user.id).all()
for _i in range(5):
aliases.append(Alias.create_new_random(user))
Session.commit()
found_aliases = ExportUserDataJob(user)._get_paginated_model(Alias, 2)
assert len(found_aliases) == len(aliases)
def test_send_report():
user = create_new_user()
ExportUserDataJob(user).run()
def test_store_and_retrieve():
user = create_new_user()
export_job = ExportUserDataJob(user)
db_job = export_job.store_job_in_db()
assert db_job is not None
export_from_from_db = ExportUserDataJob.create_from_job(db_job)
assert export_job._user.id == export_from_from_db._user.id
def test_double_store_fails():
user = create_new_user()
export_job = ExportUserDataJob(user)
db_job = export_job.store_job_in_db()
assert db_job is not None
retry = export_job.store_job_in_db()
assert retry is None

View File

@ -0,0 +1,71 @@
from app import config
from app.db import Session
from job_runner import get_jobs_to_run
from app.models import Job, JobState
import arrow
def test_get_jobs_to_run(flask_client):
now = arrow.now()
for job in Job.all():
Job.delete(job.id)
expected_jobs_to_run = [
# Jobs in ready state
Job.create(name="", payload=""),
Job.create(name="", payload="", run_at=now),
# Jobs in taken state
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS + 10)),
),
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS + 10)),
attempts=config.JOB_MAX_ATTEMPTS - 1,
),
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS + 10)),
run_at=now,
),
]
# Jobs not to run
# Job to run in the future
Job.create(name="", payload="", run_at=now.shift(hours=2))
# Job in done state
Job.create(name="", payload="", state=JobState.done.value)
# Job taken but not enough time has passed
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS - 10)),
)
# Job taken with enough time but out of run_at zone
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS + 10)),
run_at=now.shift(hours=3),
)
# Job out of attempts
Job.create(
name="",
payload="",
state=JobState.taken.value,
taken_at=now.shift(minutes=-(config.JOB_TAKEN_RETRY_WAIT_MINS + 10)),
attempts=config.JOB_MAX_ATTEMPTS + 1,
),
Session.commit()
jobs = get_jobs_to_run()
assert len(jobs) == len(expected_jobs_to_run)
job_ids = [job.id for job in jobs]
for job in expected_jobs_to_run:
assert job.id in job_ids

View File

@ -0,0 +1,14 @@
from app.mail_sender import mail_sender
from job_runner import welcome_proton
from tests.utils import create_new_user
@mail_sender.store_emails_test_decorator
def test_send_welcome_proton_email():
user = create_new_user()
welcome_proton(user)
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
sent_mail = sent_mails[0]
comm_email, _, _ = user.get_communication_email()
sent_mail.envelope_to = comm_email

View File

View File

@ -0,0 +1,25 @@
from app.models import Partner, PartnerApiToken
from app.utils import random_string
def test_generate_partner_api_token(flask_client):
partner = Partner.create(
name=random_string(10),
contact_email="{s}@{s}.com".format(s=random_string(10)),
commit=True,
)
partner_api_token, token = PartnerApiToken.generate(partner.id, None)
assert token is not None
assert len(token) > 0
assert partner_api_token.partner_id == partner.id
assert partner_api_token.expiration_time is None
hmaced = PartnerApiToken.hmac_token(token)
assert hmaced == partner_api_token.token
retrieved_partner = Partner.find_by_token(token)
assert retrieved_partner is not None
assert retrieved_partner.id == partner.id

View File

@ -0,0 +1,39 @@
from arrow import Arrow
from app.models import Partner, PartnerUser, PartnerSubscription
from app.utils import random_string
from tests.utils import create_new_user, random_email
def test_generate_partner_subscription(flask_client):
external_user_id = random_string()
partner = Partner.create(
name=random_string(10),
contact_email=random_email(),
commit=True,
)
user = create_new_user()
partner_user = PartnerUser.create(
user_id=user.id,
partner_id=partner.id,
partner_email=random_email(),
external_user_id=external_user_id,
commit=True,
)
subs = PartnerSubscription.create(
partner_user_id=partner_user.id,
end_at=Arrow.utcnow().shift(hours=1),
commit=True,
)
retrieved_subscription = PartnerSubscription.find_by_user_id(user.id)
assert retrieved_subscription is not None
assert retrieved_subscription.id == subs.id
assert user.lifetime_or_active_subscription() is True
def test_partner_subscription_for_not_partner_subscription_user(flask_client):
unexistant_subscription = PartnerSubscription.find_by_user_id(999999)
assert unexistant_subscription is None

View File

@ -0,0 +1,31 @@
from app import config
from app.db import Session
from app.models import User, Job
from tests.utils import create_new_user, random_email
def test_available_sl_domains(flask_client):
user = create_new_user()
assert set(user.available_sl_domains()) == {"d1.test", "d2.test", "sl.local"}
def test_create_from_partner(flask_client):
user = User.create(email=random_email(), from_partner=True)
assert User.FLAG_CREATED_FROM_PARTNER == (
user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert user.notification is False
assert user.trial_end is None
job = Session.query(Job).order_by(Job.id.desc()).first()
assert job is not None
assert job.name == config.JOB_SEND_PROTON_WELCOME_1
assert job.payload.get("user_id") == user.id
def test_user_created_by_partner(flask_client):
user_from_partner = User.create(email=random_email(), from_partner=True)
assert user_from_partner.created_by_partner is True
regular_user = User.create(email=random_email())
assert regular_user.created_by_partner is False

View File

View File

@ -0,0 +1,749 @@
import base64
import json
from urllib.parse import urlparse, parse_qs
from flask import url_for
from app.db import Session
from app.jose_utils import verify_id_token, decode_id_token
from app.models import Client, User, ClientUser, RedirectUri
from app.oauth.views.authorize import (
get_host_name_and_scheme,
generate_access_token,
construct_url,
)
from tests.utils import login, random_domain, random_string, random_email
def generate_random_uri() -> str:
return f"https://{random_domain()}/callback"
def test_get_host_name_and_scheme():
assert get_host_name_and_scheme("http://localhost:8000?a=b") == (
"localhost",
"http",
)
assert get_host_name_and_scheme(
"https://www.bubblecode.net/en/2016/01/22/understanding-oauth2/#Implicit_Grant"
) == ("www.bubblecode.net", "https")
def test_generate_access_token(flask_client):
access_token = generate_access_token()
assert len(access_token) == 40
def test_construct_url():
url = construct_url("http://ab.cd", {"x": "1 2"})
assert url == "http://ab.cd?x=1%202"
def test_authorize_page_non_login_user(flask_client):
"""make sure to display login page for non-authenticated user"""
user = User.create(random_email(), random_string())
Session.commit()
client = Client.create_new(random_string(), user.id)
Session.commit()
uri = generate_random_uri()
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="code",
)
)
html = r.get_data(as_text=True)
assert r.status_code == 200
assert "Sign in to accept sharing data with" in html
def test_authorize_page_login_user_non_supported_flow(flask_client):
"""return 400 if the flow is not supported"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
# Not provide any flow
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri="http://localhost",
# not provide response_type param here
)
)
# Provide a not supported flow
html = r.get_data(as_text=True)
assert r.status_code == 400
assert "SimpleLogin only support the following OIDC flows" in html
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri="http://localhost",
# SL does not support this flow combination
response_type="code token id_token",
)
)
html = r.get_data(as_text=True)
assert r.status_code == 400
assert "SimpleLogin only support the following OIDC flows" in html
def test_authorize_page_login_user(flask_client):
"""make sure to display authorization page for authenticated user"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
uri = generate_random_uri()
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="code",
)
)
html = r.get_data(as_text=True)
assert r.status_code == 200
assert f"{user.email} (Personal Email)" in html
def test_authorize_code_flow_no_openid_scope(flask_client):
"""make sure the authorize redirects user to correct page for the *Code Flow*
and when the *openid* scope is not present
, ie when response_type=code, openid not in scope
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="code",
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
assert not o.fragment
# parse the query, should return something like
# {'state': ['teststate'], 'code': ['knuyjepwvg']}
queries = parse_qs(o.query)
assert len(queries) == 2
assert queries["state"] == ["teststate"]
assert len(queries["code"]) == 1
# Exchange the code to get access_token
basic_auth_headers = base64.b64encode(
f"{client.oauth_client_id}:{client.oauth_client_secret}".encode()
).decode("utf-8")
r = flask_client.post(
url_for("oauth.token"),
headers={"Authorization": "Basic " + basic_auth_headers},
data={"grant_type": "authorization_code", "code": queries["code"][0]},
)
# r.json should have this format
# {
# 'access_token': 'avmhluhonsouhcwwailydwvhankspptgidoggcbu',
# 'expires_in': 3600,
# 'scope': '',
# 'token_type': 'bearer',
# 'user': {
# 'avatar_url': None,
# 'client': 'test client',
# 'email': 'x@y.z',
# 'email_verified': True,
# 'id': 1,
# 'name': 'AB CD'
# }
# }
assert r.status_code == 200
assert r.json["access_token"]
assert r.json["expires_in"] == 3600
assert not r.json["scope"]
assert r.json["token_type"] == "Bearer"
client_user = ClientUser.get_by(client_id=client.id)
assert r.json["user"] == {
"avatar_url": None,
"client": "test client",
"email": "x@y.z",
"email_verified": True,
"id": client_user.id,
"name": "AB CD",
"sub": str(client_user.id),
}
def test_authorize_code_flow_with_openid_scope(flask_client):
"""make sure the authorize redirects user to correct page for the *Code Flow*
and when the *openid* scope is present
, ie when response_type=code, openid in scope
The authorize endpoint should stay the same: return the *code*.
The token endpoint however should now return id_token in addition to the access_token
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="code",
scope="openid", # openid is in scope
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
assert not o.fragment
# parse the query, should return something like
# {'state': ['teststate'], 'code': ['knuyjepwvg'], 'scope': ["openid"]}
queries = parse_qs(o.query)
assert len(queries) == 3
assert queries["state"] == ["teststate"]
assert len(queries["code"]) == 1
# Exchange the code to get access_token
basic_auth_headers = base64.b64encode(
f"{client.oauth_client_id}:{client.oauth_client_secret}".encode()
).decode("utf-8")
r = flask_client.post(
url_for("oauth.token"),
headers={"Authorization": "Basic " + basic_auth_headers},
data={"grant_type": "authorization_code", "code": queries["code"][0]},
)
# r.json should have this format
# {
# 'access_token': 'avmhluhonsouhcwwailydwvhankspptgidoggcbu',
# 'expires_in': 3600,
# 'scope': '',
# 'token_type': 'bearer',
# 'user': {
# 'avatar_url': None,
# 'client': 'test client',
# 'email': 'x@y.z',
# 'email_verified': True,
# 'id': 1,
# 'name': 'AB CD'
# }
# }
assert r.status_code == 200
assert r.json["access_token"]
assert r.json["expires_in"] == 3600
assert r.json["scope"] == "openid"
assert r.json["token_type"] == "Bearer"
client_user = ClientUser.get_by(client_id=client.id)
assert r.json["user"] == {
"avatar_url": None,
"client": "test client",
"email": "x@y.z",
"email_verified": True,
"id": client_user.id,
"name": "AB CD",
"sub": str(client_user.id),
}
# id_token must be returned
assert r.json["id_token"]
# id_token must be a valid, correctly signed JWT
assert verify_id_token(r.json["id_token"])
def test_authorize_token_flow(flask_client):
"""make sure the authorize redirects user to correct page for the *Token Flow*
, ie when response_type=token
The /authorize endpoint should return an access_token
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="token", # token flow
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
# in token flow, access_token is in fragment and not query
assert o.fragment
assert not o.query
# parse the fragment, should return something like
# {'state': ['teststate'], 'access_token': ['knuyjepwvg']}
queries = parse_qs(o.fragment)
assert len(queries) == 2
assert queries["state"] == ["teststate"]
# access_token must be returned
assert len(queries["access_token"]) == 1
def test_authorize_id_token_flow(flask_client):
"""make sure the authorize redirects user to correct page for the *ID-Token Flow*
, ie when response_type=id_token
The /authorize endpoint should return an id_token
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="id_token", # id_token flow
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
assert not o.fragment
assert o.query
# parse the fragment, should return something like
# {'state': ['teststate'], 'id_token': ['knuyjepwvg']}
queries = parse_qs(o.query)
assert len(queries) == 2
assert queries["state"] == ["teststate"]
# access_token must be returned
assert len(queries["id_token"]) == 1
# id_token must be a valid, correctly signed JWT
assert verify_id_token(queries["id_token"][0])
def test_authorize_token_id_token_flow(flask_client):
"""make sure the authorize redirects user to correct page for the *ID-Token Token Flow*
, ie when response_type=id_token,token
The /authorize endpoint should return an id_token and access_token
id_token, once decoded, should contain *at_hash* in payload
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="id_token token", # id_token,token flow
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
assert o.fragment
assert not o.query
# parse the fragment, should return something like
# {'state': ['teststate'], 'id_token': ['knuyjepwvg']}
queries = parse_qs(o.fragment)
assert len(queries) == 3
assert queries["state"] == ["teststate"]
# access_token must be returned
assert len(queries["id_token"]) == 1
assert len(queries["access_token"]) == 1
# id_token must be a valid, correctly signed JWT
id_token = queries["id_token"][0]
assert verify_id_token(id_token)
# make sure jwt has all the necessary fields
jwt = decode_id_token(id_token)
# payload should have this format
# {
# 'at_hash': 'jLDmoGpuOIHwxeyFEe9SKw',
# 'aud': 'testclient-sywcpwsyua',
# 'auth_time': 1565450736,
# 'avatar_url': None,
# 'client': 'test client',
# 'email': 'x@y.z',
# 'email_verified': True,
# 'exp': 1565454336,
# 'iat': 1565450736,
# 'id': 1,
# 'iss': 'http://localhost',
# 'name': 'AB CD',
# 'sub': '1'
# }
payload = json.loads(jwt.claims)
# at_hash MUST be present when the flow is id_token,token
assert "at_hash" in payload
assert "aud" in payload
assert "auth_time" in payload
assert "avatar_url" in payload
assert "client" in payload
assert "email" in payload
assert "email_verified" in payload
assert "exp" in payload
assert "iat" in payload
assert "id" in payload
assert "iss" in payload
assert "name" in payload
assert "sub" in payload
def test_authorize_code_id_token_flow(flask_client):
"""make sure the authorize redirects user to correct page for the *ID-Token Code Flow*
, ie when response_type=id_token,code
The /authorize endpoint should return an id_token, code and id_token must contain *c_hash*
The /token endpoint must return a access_token and an id_token
"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
Session.commit()
domain = random_domain()
uri = f"https://{domain}/callback"
RedirectUri.create(
client_id=client.id,
uri=uri,
commit=True,
)
# user allows client on the authorization page
r = flask_client.post(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri=uri,
response_type="id_token code", # id_token,code flow
),
data={"button": "allow", "suggested-email": "x@y.z", "suggested-name": "AB CD"},
# user will be redirected to client page, do not allow redirection here
# to assert the redirect url
# follow_redirects=True,
)
assert r.status_code == 302 # user gets redirected back to client page
# r.location will have this form http://localhost?state=teststate&code=knuyjepwvg
o = urlparse(r.location)
assert o.netloc == domain
assert not o.fragment
assert o.query
# parse the query, should return something like
# {'state': ['teststate'], 'id_token': ['knuyjepwvg'], 'code': ['longstring']}
queries = parse_qs(o.query)
assert len(queries) == 3
assert queries["state"] == ["teststate"]
assert len(queries["id_token"]) == 1
assert len(queries["code"]) == 1
# id_token must be a valid, correctly signed JWT
id_token = queries["id_token"][0]
assert verify_id_token(id_token)
# make sure jwt has all the necessary fields
jwt = decode_id_token(id_token)
# payload should have this format
# {
# 'at_hash': 'jLDmoGpuOIHwxeyFEe9SKw',
# 'aud': 'testclient-sywcpwsyua',
# 'auth_time': 1565450736,
# 'avatar_url': None,
# 'client': 'test client',
# 'email': 'x@y.z',
# 'email_verified': True,
# 'exp': 1565454336,
# 'iat': 1565450736,
# 'id': 1,
# 'iss': 'http://localhost',
# 'name': 'AB CD',
# 'sub': '1'
# }
payload = json.loads(jwt.claims)
# at_hash MUST be present when the flow is id_token,token
assert "c_hash" in payload
assert "aud" in payload
assert "auth_time" in payload
assert "avatar_url" in payload
assert "client" in payload
assert "email" in payload
assert "email_verified" in payload
assert "exp" in payload
assert "iat" in payload
assert "id" in payload
assert "iss" in payload
assert "name" in payload
assert "sub" in payload
# <<< Exchange the code to get access_token >>>
basic_auth_headers = base64.b64encode(
f"{client.oauth_client_id}:{client.oauth_client_secret}".encode()
).decode("utf-8")
r = flask_client.post(
url_for("oauth.token"),
headers={"Authorization": "Basic " + basic_auth_headers},
data={"grant_type": "authorization_code", "code": queries["code"][0]},
)
# r.json should have this format
# {
# 'access_token': 'avmhluhonsouhcwwailydwvhankspptgidoggcbu',
# 'id_token': 'ab.cd.xy',
# 'expires_in': 3600,
# 'scope': '',
# 'token_type': 'bearer',
# 'user': {
# 'avatar_url': None,
# 'client': 'test client',
# 'email': 'x@y.z',
# 'email_verified': True,
# 'id': 1,
# 'name': 'AB CD'
# }
# }
assert r.status_code == 200
assert r.json["access_token"]
assert r.json["expires_in"] == 3600
assert not r.json["scope"]
assert r.json["token_type"] == "Bearer"
client_user = ClientUser.get_by(client_id=client.id)
assert r.json["user"] == {
"avatar_url": None,
"client": "test client",
"email": "x@y.z",
"email_verified": True,
"id": client_user.id,
"name": "AB CD",
"sub": str(client_user.id),
}
# id_token must be returned
assert r.json["id_token"]
# id_token must be a valid, correctly signed JWT
assert verify_id_token(r.json["id_token"])
def test_authorize_page_invalid_client_id(flask_client):
"""make sure to redirect user to redirect_url?error=invalid_client_id"""
user = login(flask_client)
Client.create_new("test client", user.id)
Session.commit()
r = flask_client.get(
url_for(
"oauth.authorize",
client_id="invalid_client_id",
state="teststate",
redirect_uri="http://localhost",
response_type="code",
)
)
assert r.status_code == 302
assert r.location == url_for("auth.login")
def test_authorize_page_http_not_allowed(flask_client):
"""make sure to redirect user to redirect_url?error=http_not_allowed"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
client.approved = True
Session.commit()
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri="http://mywebsite.com",
response_type="code",
)
)
assert r.status_code == 302
assert r.location == url_for("dashboard.index")
def test_authorize_page_unknown_redirect_uri(flask_client):
"""make sure to redirect user to redirect_url?error=unknown_redirect_uri"""
user = login(flask_client)
client = Client.create_new("test client", user.id)
client.approved = True
Session.commit()
r = flask_client.get(
url_for(
"oauth.authorize",
client_id=client.oauth_client_id,
state="teststate",
redirect_uri="https://unknown.com",
response_type="code",
)
)
assert r.status_code == 302
assert r.location == url_for("dashboard.index")

View File

View File

@ -0,0 +1,103 @@
from arrow import Arrow
from app.account_linking import (
SLPlan,
SLPlanType,
)
from app.proton.proton_client import ProtonClient, UserInformation
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
generate_account_not_allowed_to_log_in,
)
from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import random_string
from typing import Optional
from tests.utils import random_email
class MockProtonClient(ProtonClient):
def __init__(self, user: Optional[UserInformation]):
self.user = user
def get_user(self) -> Optional[UserInformation]:
return self.user
def test_proton_callback_handler_unexistant_sl_user():
email = random_email()
name = random_string()
external_id = random_string()
handler = ProtonCallbackHandler(
MockProtonClient(
user=UserInformation(
email=email,
name=name,
id=external_id,
plan=SLPlan(
type=SLPlanType.Premium, expiration=Arrow.utcnow().shift(hours=2)
),
)
)
)
res = handler.handle_login(get_proton_partner())
assert res.user is not None
assert res.user.email == email
assert res.user.name == name
# Ensure the user is not marked as created from partner
assert User.FLAG_CREATED_FROM_PARTNER != (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.notification is True
assert res.user.trial_end is not None
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=res.user.id
)
assert partner_user is not None
assert partner_user.external_user_id == external_id
def test_proton_callback_handler_existant_sl_user():
email = random_email()
sl_user = User.create(email, commit=True)
external_id = random_string()
user = UserInformation(
email=email,
name=random_string(),
id=external_id,
plan=SLPlan(type=SLPlanType.Premium, expiration=Arrow.utcnow().shift(hours=2)),
)
handler = ProtonCallbackHandler(MockProtonClient(user=user))
res = handler.handle_login(get_proton_partner())
assert res.user is not None
assert res.user.id == sl_user.id
# Ensure the user is not marked as created from partner
assert User.FLAG_CREATED_FROM_PARTNER != (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.notification is True
assert res.user.trial_end is not None
sa = PartnerUser.get_by(user_id=sl_user.id, partner_id=get_proton_partner().id)
assert sa is not None
assert sa.partner_email == user.email
def test_proton_callback_handler_none_user_login():
handler = ProtonCallbackHandler(MockProtonClient(user=None))
res = handler.handle_login(get_proton_partner())
expected = generate_account_not_allowed_to_log_in()
assert res == expected
def test_proton_callback_handler_none_user_link():
sl_user = User.create(random_email(), commit=True)
handler = ProtonCallbackHandler(MockProtonClient(user=None))
res = handler.handle_link(sl_user, get_proton_partner())
expected = generate_account_not_allowed_to_log_in()
assert res == expected

View File

@ -0,0 +1,21 @@
import pytest
from app.proton import proton_client
def test_convert_access_token_valid():
res = proton_client.convert_access_token("pt-abc-123")
assert res.session_id == "abc"
assert res.access_token == "123"
def test_convert_access_token_not_containing_pt():
with pytest.raises(Exception):
proton_client.convert_access_token("pb-abc-123")
def test_convert_access_token_not_containing_invalid_length():
cases = ["pt-abc-too-long", "pt-short"]
for case in cases:
with pytest.raises(Exception):
proton_client.convert_access_token(case)

71
app/tests/test.env Normal file
View File

@ -0,0 +1,71 @@
# Server url
URL=http://localhost
LOCAL_FILE_UPLOAD=1
# Email related settings
# Only print email content, not sending it
NOT_SEND_EMAIL=true
EMAIL_DOMAIN=sl.local
OTHER_ALIAS_DOMAINS=["d1.test", "d2.test", "sl.local"]
SUPPORT_EMAIL=support@sl.local
ADMIN_EMAIL=to_fill
# Max number emails user can generate for free plan
MAX_NB_EMAIL_FREE_PLAN=3
EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
DB_URI=postgresql://test:test@localhost:15432/test
# Flask
FLASK_SECRET=secret
# AWS
BUCKET=to_fill
AWS_ACCESS_KEY_ID=to_fill
AWS_SECRET_ACCESS_KEY=to_fill
# Paddle
PADDLE_VENDOR_ID=1
PADDLE_MONTHLY_PRODUCT_ID=2
PADDLE_YEARLY_PRODUCT_ID=3
PADDLE_PUBLIC_KEY_PATH=local_data/paddle.key.pub
# OpenId key
OPENID_PRIVATE_KEY_PATH=local_data/jwtRS256.key
OPENID_PUBLIC_KEY_PATH=local_data/jwtRS256.key.pub
# Words to generate random email alias
WORDS_FILE_PATH=local_data/test_words.txt
# Github
GITHUB_CLIENT_ID=to_fill
GITHUB_CLIENT_SECRET=to_fill
# Google
GOOGLE_CLIENT_ID=to_fill
GOOGLE_CLIENT_SECRET=to_fill
# Facebook
FACEBOOK_CLIENT_ID=to_fill
FACEBOOK_CLIENT_SECRET=to_fill
PGP_SENDER_PRIVATE_KEY_PATH=local_data/private-pgp.asc
ALIAS_AUTOMATIC_DISABLE=true
ALLOWED_REDIRECT_DOMAINS=["test.simplelogin.local"]
DMARC_CHECK_ENABLED=true
PROTON_CLIENT_ID=to_fill
PROTON_CLIENT_SECRET=to_fill
PROTON_BASE_URL=https://localhost/api
POSTMASTER=postmaster@test.domain
RECOVERY_CODE_HMAC_SECRET=1234567890123456789
ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT=true
MAX_NB_REVERSE_ALIAS_REPLACEMENT=200
MEM_STORE_URI=redis://localhost

View File

@ -0,0 +1,379 @@
import pytest
from arrow import Arrow
from app.account_linking import (
process_link_case,
process_login_case,
get_login_strategy,
ensure_partner_user_exists_for_user,
NewUserStrategy,
ExistingUnlinkedUserStrategy,
LinkedWithAnotherPartnerUserStrategy,
SLPlan,
SLPlanType,
PartnerLinkRequest,
ClientMergeStrategy,
)
from app.db import Session
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.models import Partner, PartnerUser, User
from app.proton.utils import get_proton_partner
from app.utils import random_string
from tests.utils import random_email
def random_link_request(
external_user_id: str = None,
name: str = None,
email: str = None,
plan: SLPlan = None,
from_partner: bool = False,
) -> PartnerLinkRequest:
external_user_id = (
external_user_id if external_user_id is not None else random_string()
)
name = name if name is not None else random_string()
email = email if email is not None else random_email()
plan = plan if plan is not None else SLPlanType.Free
return PartnerLinkRequest(
name=name,
email=email,
external_user_id=external_user_id,
plan=SLPlan(type=plan, expiration=Arrow.utcnow().shift(hours=2)),
from_partner=from_partner,
)
def create_user(email: str = None) -> User:
email = email if email is not None else random_email()
user = User.create(email=email)
Session.commit()
return user
def create_user_for_partner(external_user_id: str, email: str = None) -> User:
email = email if email is not None else random_email()
user = User.create(email=email)
PartnerUser.create(
user_id=user.id,
partner_id=get_proton_partner().id,
partner_email=email,
external_user_id=external_user_id,
)
Session.commit()
return user
def test_get_strategy_unexistant_sl_user():
strategy = get_login_strategy(
link_request=random_link_request(),
user=None,
partner=get_proton_partner(),
)
assert isinstance(strategy, NewUserStrategy)
def test_login_case_from_partner():
partner = get_proton_partner()
res = process_login_case(
random_link_request(
external_user_id=random_string(),
from_partner=True,
),
partner,
)
assert res.strategy == NewUserStrategy.__name__
assert res.user is not None
assert User.FLAG_CREATED_FROM_PARTNER == (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.activated is True
def test_login_case_from_partner_with_uppercase_email():
partner = get_proton_partner()
link_request = random_link_request(
external_user_id=random_string(),
from_partner=True,
)
link_request.email = link_request.email.upper()
res = process_login_case(link_request, partner)
assert res.strategy == NewUserStrategy.__name__
assert res.user is not None
assert res.user.email == link_request.email.lower()
assert User.FLAG_CREATED_FROM_PARTNER == (
res.user.flags & User.FLAG_CREATED_FROM_PARTNER
)
assert res.user.activated is True
def test_login_case_from_web():
partner = get_proton_partner()
res = process_login_case(
random_link_request(
external_user_id=random_string(),
from_partner=False,
),
partner,
)
assert res.strategy == NewUserStrategy.__name__
assert res.user is not None
assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER)
assert res.user.activated is True
def test_get_strategy_existing_sl_user():
email = random_email()
user = User.create(email, commit=True)
strategy = get_login_strategy(
link_request=random_link_request(email=email),
user=user,
partner=get_proton_partner(),
)
assert isinstance(strategy, ExistingUnlinkedUserStrategy)
def test_get_strategy_existing_sl_user_with_uppercase_email():
email = random_email()
user = User.create(email, commit=True)
strategy = get_login_strategy(
link_request=random_link_request(email=email.upper()),
user=user,
partner=get_proton_partner(),
)
assert isinstance(strategy, ExistingUnlinkedUserStrategy)
def test_get_strategy_existing_sl_user_linked_with_different_proton_account():
# In this scenario we have
# - PartnerUser1 (ID1, email1@proton)
# - PartnerUser2 (ID2, email2@proton)
# - SimpleLoginUser1 registered with email1@proton, but linked to account ID2
# We will try to log in with email1@proton
email1 = random_email()
email2 = random_email()
partner_user_id_1 = random_string()
partner_user_id_2 = random_string()
link_request_1 = random_link_request(
external_user_id=partner_user_id_1, email=email1
)
link_request_2 = random_link_request(
external_user_id=partner_user_id_2, email=email2
)
user = create_user_for_partner(
link_request_2.external_user_id, email=link_request_1.email
)
strategy = get_login_strategy(
link_request=link_request_1,
user=user,
partner=get_proton_partner(),
)
assert isinstance(strategy, LinkedWithAnotherPartnerUserStrategy)
##
# LINK
def test_link_account_with_proton_account_same_address(flask_client):
# This is the most basic scenario
# In this scenario we have:
# - PartnerUser (email1@partner)
# - SimpleLoginUser registered with email1@proton
# We will try to link both accounts
email = random_email()
partner_user_id = random_string()
link_request = random_link_request(external_user_id=partner_user_id, email=email)
user = create_user(email)
res = process_link_case(link_request, user, get_proton_partner())
assert res is not None
assert res.user is not None
assert res.user.id == user.id
assert res.user.email == email
assert res.strategy == "Link"
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=user.id
)
assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id
def test_link_account_with_proton_account_different_address(flask_client):
# In this scenario we have:
# - ProtonUser (foo@proton)
# - SimpleLoginUser (bar@somethingelse)
# We will try to link both accounts
partner_user_id = random_string()
link_request = random_link_request(
external_user_id=partner_user_id, email=random_email()
)
user = create_user()
res = process_link_case(link_request, user, get_proton_partner())
assert res.user.id == user.id
assert res.user.email == user.email
assert res.strategy == "Link"
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=user.id
)
assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id
def test_link_account_with_proton_account_same_address_but_linked_to_other_user(
flask_client,
):
# In this scenario we have:
# - PartnerUser (foo@partner)
# - SimpleLoginUser1 (foo@partner)
# - SimpleLoginUser2 (other@somethingelse) linked with foo@partner
# We will unlink SimpleLoginUser2 and link SimpleLoginUser1 with foo@partner
partner_user_id = random_string()
partner_email = random_email()
link_request = random_link_request(
external_user_id=partner_user_id, email=partner_email
)
sl_user_1 = create_user(partner_email)
sl_user_2 = create_user_for_partner(
partner_user_id, email=random_email()
) # User already linked with the proton account
res = process_link_case(link_request, sl_user_1, get_proton_partner())
assert res.user.id == sl_user_1.id
assert res.user.email == partner_email
assert res.strategy == "Link"
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=sl_user_1.id
)
assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=sl_user_2.id
)
assert partner_user is None
def test_link_account_with_proton_account_different_address_and_linked_to_other_user(
flask_client,
):
# In this scenario we have:
# - PartnerUser (foo@partner)
# - SimpleLoginUser1 (bar@somethingelse)
# - SimpleLoginUser2 (other@somethingelse) linked with foo@partner
# We will unlink SimpleLoginUser2 and link SimpleLoginUser1 with foo@partner
partner_user_id = random_string()
link_request = random_link_request(
external_user_id=partner_user_id, email=random_email()
)
sl_user_1 = create_user(random_email())
sl_user_2 = create_user_for_partner(
partner_user_id, email=random_email()
) # User already linked with the proton account
res = process_link_case(link_request, sl_user_1, get_proton_partner())
assert res.user.id == sl_user_1.id
assert res.user.email == sl_user_1.email
assert res.strategy == "Link"
partner_user_1 = PartnerUser.get_by(
user_id=sl_user_1.id, partner_id=get_proton_partner().id
)
assert partner_user_1 is not None
assert partner_user_1.partner_email == sl_user_2.email
assert partner_user_1.partner_id == get_proton_partner().id
assert partner_user_1.external_user_id == partner_user_id
partner_user_2 = PartnerUser.get_by(
user_id=sl_user_2.id, partner_id=get_proton_partner().id
)
assert partner_user_2 is None
def test_cannot_create_instance_of_base_strategy():
with pytest.raises(Exception):
ClientMergeStrategy(random_link_request(), None, get_proton_partner())
def test_ensure_partner_user_exists_for_user_raises_exception_when_linked_to_another_partner():
# Setup test data:
# - partner_1
# - partner_2
# - user
user_email = random_email()
user = create_user(user_email)
external_id_1 = random_string()
partner_1 = Partner.create(
name=random_string(),
contact_email=random_email(),
)
external_id_2 = random_string()
partner_2 = Partner.create(
name=random_string(),
contact_email=random_email(),
)
# Link user with partner_1
ensure_partner_user_exists_for_user(
PartnerLinkRequest(
name=random_string(),
email=user_email,
external_user_id=external_id_1,
plan=SLPlan(type=SLPlanType.Free, expiration=None),
from_partner=False,
),
user,
partner_1,
)
# Try to link user with partner_2 and confirm the exception
with pytest.raises(AccountAlreadyLinkedToAnotherPartnerException):
ensure_partner_user_exists_for_user(
PartnerLinkRequest(
name=random_string(),
email=user_email,
external_user_id=external_id_2,
plan=SLPlan(type=SLPlanType.Free, expiration=None),
from_partner=False,
),
user,
partner_2,
)
def test_link_account_with_uppercase(flask_client):
# In this scenario we have:
# - PartnerUser (email1@partner)
# - SimpleLoginUser registered with email1@proton
# We will try to link both accounts with an uppercase email
email = random_email()
partner_user_id = random_string()
link_request = random_link_request(
external_user_id=partner_user_id, email=email.upper()
)
user = create_user(email)
res = process_link_case(link_request, user, get_proton_partner())
assert res is not None
assert res.user is not None
assert res.user.id == user.id
assert res.user.email == email
assert res.strategy == "Link"
partner_user = PartnerUser.get_by(
partner_id=get_proton_partner().id, user_id=user.id
)
assert partner_user.partner_id == get_proton_partner().id
assert partner_user.external_user_id == partner_user_id

View File

@ -0,0 +1,126 @@
from typing import List
from app.alias_utils import (
delete_alias,
check_alias_prefix,
get_user_if_alias_would_auto_create,
try_auto_create,
)
from app.config import ALIAS_DOMAINS
from app.db import Session
from app.models import (
Alias,
DeletedAlias,
CustomDomain,
AutoCreateRule,
Directory,
DirectoryMailbox,
User,
)
from tests.utils import create_new_user, random_domain, random_token
def test_delete_alias(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
assert Alias.get_by(email=alias.email)
delete_alias(alias, user)
assert Alias.get_by(email=alias.email) is None
assert DeletedAlias.get_by(email=alias.email)
def test_delete_alias_already_in_trash(flask_client):
"""delete an alias that's already in alias trash"""
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
# add the alias to global trash
Session.add(DeletedAlias(email=alias.email))
Session.commit()
delete_alias(alias, user)
assert Alias.get_by(email=alias.email) is None
def test_check_alias_prefix(flask_client):
assert check_alias_prefix("ab-cd_")
assert not check_alias_prefix("")
assert not check_alias_prefix("éè")
assert not check_alias_prefix("a b")
assert not check_alias_prefix("+👌")
assert not check_alias_prefix("too-long" * 10)
def get_auto_create_alias_tests(user: User) -> List:
user.lifetime = True
catchall = CustomDomain.create(
user_id=user.id,
catch_all=True,
domain=random_domain(),
verified=True,
flush=True,
)
no_catchall = CustomDomain.create(
user_id=user.id,
catch_all=False,
domain=random_domain(),
verified=True,
flush=True,
)
no_catchall_with_rule = CustomDomain.create(
user_id=user.id,
catch_all=False,
domain=random_domain(),
verified=True,
flush=True,
)
AutoCreateRule.create(
custom_domain_id=no_catchall_with_rule.id,
order=0,
regex="ok-.*",
flush=True,
)
dir_name = random_token()
directory = Directory.create(name=dir_name, user_id=user.id, flush=True)
DirectoryMailbox.create(
directory_id=directory.id, mailbox_id=user.default_mailbox_id, flush=True
)
Session.commit()
return [
(f"nonexistant@{catchall.domain}", True),
(f"nonexistant@{no_catchall.domain}", False),
(f"nonexistant@{no_catchall_with_rule.domain}", False),
(f"ok-nonexistant@{no_catchall_with_rule.domain}", True),
(f"{dir_name}+something@nowhere.net", False),
(f"{dir_name}#something@nowhere.net", False),
(f"{dir_name}/something@nowhere.net", False),
(f"{dir_name}+something@{ALIAS_DOMAINS[0]}", True),
(f"{dir_name}#something@{ALIAS_DOMAINS[0]}", True),
(f"{dir_name}/something@{ALIAS_DOMAINS[0]}", True),
]
def test_get_user_if_alias_would_auto_create(flask_client):
user = create_new_user()
for test_id, (address, expected_ok) in enumerate(get_auto_create_alias_tests(user)):
result = get_user_if_alias_would_auto_create(address)
if expected_ok:
assert (
isinstance(result, User) and result.id == user.id
), f"Case {test_id} - Failed address {address}"
else:
assert not result, f"Case {test_id} - Failed address {address}"
def test_auto_create_alias(flask_client):
user = create_new_user()
for test_id, (address, expected_ok) in enumerate(get_auto_create_alias_tests(user)):
result = try_auto_create(address)
if expected_ok:
assert result, f"Case {test_id} - Failed address {address}"
else:
assert result is None, f"Case {test_id} - Failed address {address}"

13
app/tests/test_config.py Normal file
View File

@ -0,0 +1,13 @@
import pytest
from app.config import sl_getenv
def test_sl_getenv(monkeypatch):
monkeypatch.setenv("SL_KEY_1", '["domain_1"]')
assert sl_getenv("SL_KEY_1") == ["domain_1"]
assert sl_getenv("SL_KEY_2", default_factory=list) == []
with pytest.raises(TypeError):
sl_getenv("SL_KEY_3")

38
app/tests/test_cron.py Normal file
View File

@ -0,0 +1,38 @@
import arrow
from app.models import CoinbaseSubscription, ApiToCookieToken, ApiKey
from cron import notify_manual_sub_end, delete_expired_tokens
from tests.utils import create_new_user
def test_notify_manual_sub_end(flask_client):
user = create_new_user()
CoinbaseSubscription.create(
user_id=user.id, end_at=arrow.now().shift(days=13, hours=2), commit=True
)
notify_manual_sub_end()
def test_cleanup_tokens(flask_client):
user = create_new_user()
api_key = ApiKey.create(
user_id=user.id,
commit=True,
)
id_to_clean = ApiToCookieToken.create(
user_id=user.id,
api_key_id=api_key.id,
commit=True,
created_at=arrow.now().shift(days=-1),
).id
id_to_keep = ApiToCookieToken.create(
user_id=user.id,
api_key_id=api_key.id,
commit=True,
).id
delete_expired_tokens()
assert ApiToCookieToken.get(id_to_clean) is None
assert ApiToCookieToken.get(id_to_keep) is not None

View File

@ -0,0 +1,48 @@
from app.dns_utils import (
get_mx_domains,
get_spf_domain,
get_txt_record,
is_mx_equivalent,
)
# use our own domain for test
_DOMAIN = "simplelogin.io"
def test_get_mx_domains():
r = get_mx_domains(_DOMAIN)
assert len(r) > 0
for x in r:
assert x[0] > 0
assert x[1]
def test_get_spf_domain():
r = get_spf_domain(_DOMAIN)
assert r == ["simplelogin.co"]
def test_get_txt_record():
r = get_txt_record(_DOMAIN)
assert len(r) > 0
def test_is_mx_equivalent():
assert is_mx_equivalent([], [])
assert is_mx_equivalent([(1, "domain")], [(1, "domain")])
assert is_mx_equivalent(
[(10, "domain1"), (20, "domain2")], [(10, "domain1"), (20, "domain2")]
)
assert is_mx_equivalent(
[(5, "domain1"), (10, "domain2")], [(10, "domain1"), (20, "domain2")]
)
assert is_mx_equivalent(
[(5, "domain1"), (10, "domain2"), (20, "domain3")],
[(10, "domain1"), (20, "domain2")],
)
assert not is_mx_equivalent(
[(5, "domain1"), (10, "domain2")],
[(10, "domain1"), (20, "domain2"), (20, "domain3")],
)

View File

@ -0,0 +1,310 @@
import random
from email.message import EmailMessage
from typing import List
import pytest
from aiosmtpd.smtp import Envelope
import email_handler
from app import config
from app.config import EMAIL_DOMAIN, ALERT_DMARC_FAILED_REPLY_PHASE
from app.db import Session
from app.email import headers, status
from app.email_utils import generate_verp_email
from app.mail_sender import mail_sender
from app.models import (
Alias,
AuthorizedAddress,
IgnoredEmail,
EmailLog,
Notification,
VerpType,
Contact,
SentAlert,
)
from email_handler import (
get_mailbox_from_mail_from,
should_ignore,
is_automatic_out_of_office,
)
from tests.utils import load_eml_file, create_new_user, random_email
def test_get_mailbox_from_mail_from(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
mb = get_mailbox_from_mail_from(user.email, alias)
assert mb.email == user.email
mb = get_mailbox_from_mail_from("unauthorized@gmail.com", alias)
assert mb is None
# authorized address
AuthorizedAddress.create(
user_id=user.id,
mailbox_id=user.default_mailbox_id,
email="unauthorized@gmail.com",
commit=True,
)
mb = get_mailbox_from_mail_from("unauthorized@gmail.com", alias)
assert mb.email == user.email
def test_should_ignore(flask_client):
assert should_ignore("mail_from", []) is False
assert not should_ignore("mail_from", ["rcpt_to"])
IgnoredEmail.create(mail_from="mail_from", rcpt_to="rcpt_to", commit=True)
assert should_ignore("mail_from", ["rcpt_to"])
def test_is_automatic_out_of_office():
msg = EmailMessage()
assert not is_automatic_out_of_office(msg)
msg[headers.AUTO_SUBMITTED] = "auto-replied"
assert is_automatic_out_of_office(msg)
del msg[headers.AUTO_SUBMITTED]
assert not is_automatic_out_of_office(msg)
msg[headers.AUTO_SUBMITTED] = "auto-generated"
assert is_automatic_out_of_office(msg)
def test_dmarc_forward_quarantine(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file("dmarc_quarantine.eml", {"alias_email": alias.email})
envelope = Envelope()
envelope.mail_from = msg["from"]
envelope.rcpt_tos = [msg["to"]]
result = email_handler.handle(envelope, msg)
assert result == status.E215
email_logs = (
EmailLog.filter_by(user_id=user.id, alias_id=alias.id)
.order_by(EmailLog.id.desc())
.all()
)
assert len(email_logs) == 1
email_log = email_logs[0]
assert email_log.blocked
assert email_log.refused_email_id
notifications = Notification.filter_by(user_id=user.id).all()
assert len(notifications) == 1
assert f"{alias.email} has a new mail in quarantine" == notifications[0].title
def test_gmail_dmarc_softfail(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file("dmarc_gmail_softfail.eml", {"alias_email": alias.email})
envelope = Envelope()
envelope.mail_from = msg["from"]
envelope.rcpt_tos = [msg["to"]]
result = email_handler.handle(envelope, msg)
assert result == status.E200
# Enable when we can verify that the actual message sent has this content
# payload = msg.get_payload()
# assert payload.find("failed anti-phishing checks") > -1
def test_prevent_5xx_from_spf(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file(
"5xx_overwrite_spf.eml",
{"alias_email": alias.email, "spf_result": "R_SPF_FAIL"},
)
envelope = Envelope()
envelope.mail_from = msg["from"]
# Ensure invalid email log
envelope.rcpt_tos = [generate_verp_email(VerpType.bounce_forward, 99999999999999)]
result = email_handler.MailHandler()._handle(envelope, msg)
assert status.E216 == result
def test_preserve_5xx_with_valid_spf(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file(
"5xx_overwrite_spf.eml",
{"alias_email": alias.email, "spf_result": "R_SPF_ALLOW"},
)
envelope = Envelope()
envelope.mail_from = msg["from"]
# Ensure invalid email log
envelope.rcpt_tos = [generate_verp_email(VerpType.bounce_forward, 99999999999999)]
result = email_handler.MailHandler()._handle(envelope, msg)
assert status.E512 == result
def test_preserve_5xx_with_no_header(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file(
"no_spamd_header.eml",
{"alias_email": alias.email},
)
envelope = Envelope()
envelope.mail_from = msg["from"]
# Ensure invalid email log
envelope.rcpt_tos = [generate_verp_email(VerpType.bounce_forward, 99999999999999)]
result = email_handler.MailHandler()._handle(envelope, msg)
assert status.E512 == result
def generate_dmarc_result() -> List:
return ["DMARC_POLICY_QUARANTINE", "DMARC_POLICY_REJECT", "DMARC_POLICY_SOFTFAIL"]
@pytest.mark.parametrize("dmarc_result", generate_dmarc_result())
def test_dmarc_reply_quarantine(flask_client, dmarc_result):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email="random-{}@nowhere.net".format(int(random.random())),
name="Name {}".format(int(random.random())),
reply_email="random-{}@{}".format(random.random(), EMAIL_DOMAIN),
)
Session.commit()
msg = load_eml_file(
"dmarc_reply_check.eml",
{
"alias_email": alias.email,
"contact_email": contact.reply_email,
"dmarc_result": dmarc_result,
},
)
envelope = Envelope()
envelope.mail_from = msg["from"]
envelope.rcpt_tos = [msg["to"]]
result = email_handler.handle(envelope, msg)
assert result == status.E215
alerts = SentAlert.filter_by(
user_id=user.id, alert_type=ALERT_DMARC_FAILED_REPLY_PHASE
).all()
assert len(alerts) == 1
def test_add_alias_to_header_if_needed():
msg = EmailMessage()
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
assert msg[headers.TO] is None
email_handler.add_alias_to_header_if_needed(msg, alias)
assert msg[headers.TO] == alias.email
def test_append_alias_to_header_if_needed_existing_to():
msg = EmailMessage()
original_to = "noone@nowhere.no"
msg[headers.TO] = original_to
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
email_handler.add_alias_to_header_if_needed(msg, alias)
assert msg[headers.TO] == f"{original_to}, {alias.email}"
def test_avoid_add_to_header_already_present():
msg = EmailMessage()
user = create_new_user()
alias = Alias.filter_by(user_id=user.id).first()
msg[headers.TO] = alias.email
email_handler.add_alias_to_header_if_needed(msg, alias)
assert msg[headers.TO] == alias.email
def test_avoid_add_to_header_already_present_in_cc():
msg = EmailMessage()
create_new_user()
alias = Alias.first()
msg[headers.CC] = alias.email
email_handler.add_alias_to_header_if_needed(msg, alias)
assert msg[headers.TO] is None
assert msg[headers.CC] == alias.email
def test_email_sent_to_noreply(flask_client):
msg = EmailMessage()
envelope = Envelope()
envelope.mail_from = "from@domain.test"
envelope.rcpt_tos = [config.NOREPLY]
result = email_handler.handle(envelope, msg)
assert result == status.E200
def test_email_sent_to_noreplies(flask_client):
msg = EmailMessage()
envelope = Envelope()
envelope.mail_from = "from@domain.test"
config.NOREPLIES = ["other-no-reply@sl.test"]
envelope.rcpt_tos = ["other-no-reply@sl.test"]
result = email_handler.handle(envelope, msg)
assert result == status.E200
# NOREPLY isn't used anymore
envelope.rcpt_tos = [config.NOREPLY]
result = email_handler.handle(envelope, msg)
assert result == status.E515
def test_references_header(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
msg = load_eml_file("reference_encoded.eml", {"alias_email": alias.email})
envelope = Envelope()
envelope.mail_from = "somewhere@rainbow.com"
envelope.rcpt_tos = [alias.email]
result = email_handler.handle(envelope, msg)
assert result == status.E200
@mail_sender.store_emails_test_decorator
def test_replace_contacts_and_user_in_reply_phase(flask_client):
user = create_new_user()
user.replace_reverse_alias = True
alias = Alias.create_new_random(user)
Session.flush()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=random_email(),
reply_email=f"{random.random()}@{EMAIL_DOMAIN}",
commit=True,
)
contact_real_mail = contact.website_email
contact2 = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=random_email(),
reply_email=f"{random.random()}@{EMAIL_DOMAIN}",
commit=True,
)
contact2_real_mail = contact2.website_email
msg = load_eml_file(
"replacement_on_reply_phase.eml",
{
"contact_reply_email": contact.reply_email,
"other_contact_reply_email": contact2.reply_email,
},
)
envelope = Envelope()
envelope.mail_from = alias.mailbox.email
envelope.rcpt_tos = [contact.reply_email]
result = email_handler.handle(envelope, msg)
assert result == status.E200
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
payload = sent_mails[0].msg.get_payload()[0].get_payload()
assert payload.find("Contact is {}".format(contact_real_mail)) > -1
assert payload.find("Other contact is {}".format(contact2_real_mail)) > -1

View File

@ -0,0 +1,799 @@
import email
import os
from email.message import EmailMessage
from email.utils import formataddr
import arrow
import pytest
from app.config import MAX_ALERT_24H, EMAIL_DOMAIN, ROOT_DIR
from app.db import Session
from app.email_utils import (
get_email_domain_part,
can_create_directory_for_address,
email_can_be_used_as_mailbox,
delete_header,
add_or_replace_header,
send_email_with_rate_control,
copy,
get_spam_from_header,
get_header_from_bounce,
is_valid_email,
add_header,
generate_reply_email,
normalize_reply_email,
get_encoding,
encode_text,
EmailEncoding,
replace,
should_disable,
decode_text,
parse_id_from_bounce,
get_queue_id,
should_ignore_bounce,
get_header_unicode,
parse_full_address,
get_orig_message_from_bounce,
get_mailbox_bounce_info,
is_invalid_mailbox_domain,
generate_verp_email,
get_verp_info_from_email,
sl_formataddr,
)
from app.models import (
CustomDomain,
Alias,
Contact,
EmailLog,
IgnoreBounceSender,
InvalidMailboxDomain,
VerpType,
)
# flake8: noqa: E101, W191
from tests.utils import login, load_eml_file, create_new_user, random_domain
def test_get_email_domain_part():
assert get_email_domain_part("ab@cd.com") == "cd.com"
def test_email_belongs_to_alias_domains():
# default alias domain
assert can_create_directory_for_address("ab@sl.local")
assert not can_create_directory_for_address("ab@not-exist.local")
assert can_create_directory_for_address("hey@d1.test")
assert not can_create_directory_for_address("hey@d3.test")
@pytest.mark.skipif(
"GITHUB_ACTIONS_TEST" in os.environ,
reason="this test requires DNS lookup that does not work on Github CI",
)
def test_can_be_used_as_personal_email(flask_client):
# default alias domain
assert not email_can_be_used_as_mailbox("ab@sl.local")
assert not email_can_be_used_as_mailbox("hey@d1.test")
# custom domain
domain = random_domain()
user = create_new_user()
CustomDomain.create(user_id=user.id, domain=domain, verified=True, commit=True)
assert not email_can_be_used_as_mailbox(f"hey@{domain}")
# disposable domain
disposable_domain = random_domain()
InvalidMailboxDomain.create(domain=disposable_domain, commit=True)
assert not email_can_be_used_as_mailbox(f"abcd@{disposable_domain}")
# subdomain will not work
assert not email_can_be_used_as_mailbox("abcd@sub.{disposable_domain}")
# valid domains should not be affected
assert email_can_be_used_as_mailbox("abcd@protonmail.com")
assert email_can_be_used_as_mailbox("abcd@gmail.com")
def test_delete_header():
msg = EmailMessage()
assert msg._headers == []
msg["H"] = "abcd"
msg["H"] = "xyzt"
assert msg._headers == [("H", "abcd"), ("H", "xyzt")]
delete_header(msg, "H")
assert msg._headers == []
def test_add_or_replace_header():
msg = EmailMessage()
msg["H"] = "abcd"
msg["H"] = "xyzt"
assert msg._headers == [("H", "abcd"), ("H", "xyzt")]
add_or_replace_header(msg, "H", "new")
assert msg._headers == [("H", "new")]
def test_parse_full_address():
# only email
assert parse_full_address("abcd@gmail.com") == (
"",
"abcd@gmail.com",
)
# ascii address
assert parse_full_address("First Last <abcd@gmail.com>") == (
"First Last",
"abcd@gmail.com",
)
# Handle quote
assert parse_full_address('"First Last" <abcd@gmail.com>') == (
"First Last",
"abcd@gmail.com",
)
# UTF-8 charset
assert parse_full_address("=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <abcd@gmail.com>") == (
"Nhơn Nguyễn",
"abcd@gmail.com",
)
# iso-8859-1 charset
assert parse_full_address("=?iso-8859-1?q?p=F6stal?= <abcd@gmail.com>") == (
"pöstal",
"abcd@gmail.com",
)
with pytest.raises(ValueError):
parse_full_address("https://ab.cd")
def test_send_email_with_rate_control(flask_client):
user = create_new_user()
for _ in range(MAX_ALERT_24H):
assert send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
)
assert not send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
)
def test_get_spam_from_header():
is_spam, _ = get_spam_from_header(
"""No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2"""
)
assert not is_spam
is_spam, _ = get_spam_from_header(
"""Yes, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2"""
)
assert is_spam
# the case where max_score is less than the default used by SpamAssassin
is_spam, _ = get_spam_from_header(
"""No, score=6 required=10.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2""",
max_score=5,
)
assert is_spam
def test_get_header_from_bounce():
# this is an actual bounce report from iCloud anonymized
msg_str = """Received: by mx1.simplelogin.co (Postfix)
id 9988776655; Mon, 24 Aug 2020 06:20:07 +0000 (UTC)
Date: Mon, 24 Aug 2020 06:20:07 +0000 (UTC)
From: MAILER-DAEMON@bounce.simplelogin.io (Mail Delivery System)
Subject: Undelivered Mail Returned to Sender
To: reply+longstring@simplelogin.co
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="XXYYZZTT.1598250007/mx1.simplelogin.co"
Content-Transfer-Encoding: 8bit
Message-Id: <20200824062007.9988776655@mx1.simplelogin.co>
This is a MIME-encapsulated message.
--XXYYZZTT.1598250007/mx1.simplelogin.co
Content-Description: Notification
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit
This is the mail system at host mx1.simplelogin.co.
I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.
For further assistance, please send mail to <postmaster@simplelogin.io>
If you do so, please include this problem report. You can
delete your own text from the attached returned message.
The mail system
<something@icloud.com>: host mx01.mail.icloud.com[17.57.154.6] said:
554 5.7.1 [CS01] Message rejected due to local policy. Please visit
https://support.apple.com/en-us/HT204137 (in reply to end of DATA command)
--XXYYZZTT.1598250007/mx1.simplelogin.co
Content-Description: Delivery report
Content-Type: message/delivery-status
Reporting-MTA: dns; mx1.simplelogin.co
X-Postfix-Queue-ID: XXYYZZTT
X-Postfix-Sender: rfc822; reply+longstring@simplelogin.co
Arrival-Date: Mon, 24 Aug 2020 06:20:04 +0000 (UTC)
Final-Recipient: rfc822; something@icloud.com
Original-Recipient: rfc822;something@icloud.com
Action: failed
Status: 5.7.1
Remote-MTA: dns; mx01.mail.icloud.com
Diagnostic-Code: smtp; 554 5.7.1 [CS01] Message rejected due to local policy.
Please visit https://support.apple.com/en-us/HT204137
--XXYYZZTT.1598250007/mx1.simplelogin.co
Content-Description: Undelivered Message Headers
Content-Type: text/rfc822-headers
Content-Transfer-Encoding: 8bit
Return-Path: <reply+longstring@simplelogin.co>
X-SimpleLogin-Client-IP: 172.17.0.4
Received: from [172.17.0.4] (unknown [172.17.0.4])
by mx1.simplelogin.co (Postfix) with ESMTP id XXYYZZTT
for <something@icloud.com>; Mon, 24 Aug 2020 06:20:04 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=91.241.74.242;
helo=mail23-242.srv2.de; envelope-from=return@mailing.dhl.de;
receiver=<UNKNOWN>
Received: from mail23-242.srv2.de (mail23-242.srv2.de [91.241.74.242])
(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits))
(No client certificate requested)
by mx1.simplelogin.co (Postfix) with ESMTPS id B7D123F1C6
for <dhl@something.com>; Mon, 24 Aug 2020 06:20:03 +0000 (UTC)
Message-ID: <368362807.12707001.1598249997169@rnd-04.broadmail.live>
MIME-Version: 1.0
Content-Type: multipart/signed; protocol="application/pkcs7-signature";
micalg=sha-256;
boundary="----=_Part_12707000_248822956.1598249997168"
Date: Mon, 24 Aug 2020 08:19:57 +0200 (CEST)
To: dhl@something.com
Subject: Test subject
X-ulpe:
re-pO_5F8NoxrdpyqkmsptkpyTxDqB3osb7gfyo-41ZOK78E-3EOXXNLB-FKZPLZ@mailing.dhl.de
List-Id: <1CZ4Z7YB-1DYLQB8.mailing.dhl.de>
X-Report-Spam: complaints@episerver.com
X-CSA-Complaints: whitelist-complaints@eco.de
List-Unsubscribe-Post: List-Unsubscribe=One-Click
mkaTechnicalID: 123456
Feedback-ID: 1CZ4Z7YB:3EOXXNLB:episerver
X-SimpleLogin-Type: Forward
X-SimpleLogin-Mailbox-ID: 1234
X-SimpleLogin-EmailLog-ID: 654321
From: "DHL Paket - noreply@dhl.de"
<reply+longstring@simplelogin.co>
List-Unsubscribe: <mailto:unsubsribe@simplelogin.co?subject=123456=>
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=simplelogin.co;
i=@simplelogin.co; q=dns/txt; s=dkim; t=1598250004; h=from : to;
bh=nXVR9uziNfqtwyhq6gQLFJvFtdyQ8WY/w7c1mCaf7bg=;
b=QY/Jb4ls0zFOqExWFkwW9ZOKNvkYPDsj74ar1LNm703kyL341KwX3rGnnicrLV7WxYo8+
pBY0HO7OSAJEOqmYdagAlVouuFiBMUtS2Jw/jiPHzcuvunE9JFOZFRUnNMKrr099i10U4H9
ZwE8i6lQzG6IMN4spjxJ2HCO8hiB3AU=
--XXYYZZTT.1598250007/mx1.simplelogin.co--
"""
assert (
get_header_from_bounce(
email.message_from_string(msg_str), "X-SimpleLogin-Mailbox-ID"
)
== "1234"
)
assert (
get_header_from_bounce(
email.message_from_string(msg_str), "X-SimpleLogin-EmailLog-ID"
)
== "654321"
)
assert (
get_header_from_bounce(email.message_from_string(msg_str), "Not-exist") is None
)
def test_is_valid_email():
assert is_valid_email("abcd@gmail.com")
assert not is_valid_email("")
assert not is_valid_email(" ")
assert not is_valid_email("with space@gmail.com")
assert not is_valid_email("strange char !ç@gmail.com")
assert not is_valid_email("emoji👌@gmail.com")
def test_add_header_plain_text():
msg = email.message_from_string(
"""Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Test-Header: Test-Value
coucou
"""
)
new_msg = add_header(msg, "text header", "html header")
assert "text header" in new_msg.as_string()
assert "html header" not in new_msg.as_string()
def test_add_header_html():
msg = email.message_from_string(
"""Content-Type: text/html; charset=us-ascii
Content-Transfer-Encoding: 7bit
Test-Header: Test-Value
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii">
</head>
<body style="word-wrap: break-word;" class="">
<b class="">bold</b>
</body>
</html>
"""
)
new_msg = add_header(msg, "text header", "html header")
assert "Test-Header: Test-Value" in new_msg.as_string()
assert "<table" in new_msg.as_string()
assert "</table>" in new_msg.as_string()
assert "html header" in new_msg.as_string()
assert "text header" not in new_msg.as_string()
def test_add_header_multipart_alternative():
msg = email.message_from_string(
"""Content-Type: multipart/alternative;
boundary="foo"
Content-Transfer-Encoding: 7bit
Test-Header: Test-Value
--foo
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=us-ascii
bold
--foo
Content-Transfer-Encoding: 7bit
Content-Type: text/html;
charset=us-ascii
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii">
</head>
<body style="word-wrap: break-word;" class="">
<b class="">bold</b>
</body>
</html>
"""
)
new_msg = add_header(msg, "text header", "html header")
assert "Test-Header: Test-Value" in new_msg.as_string()
assert "<table" in new_msg.as_string()
assert "</table>" in new_msg.as_string()
assert "html header" in new_msg.as_string()
assert "text header" in new_msg.as_string()
def test_replace_no_encoding():
msg = email.message_from_string(
"""Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Test-Header: Test-Value
old
"""
)
new_msg = replace(msg, "old", "new")
assert "new" in new_msg.as_string()
assert "old" not in new_msg.as_string()
# headers are not affected
assert "Test-Header: Test-Value" in new_msg.as_string()
def test_replace_base64_encoding():
# "b2xk" is "old" base64-encoded
msg = email.message_from_string(
"""Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: base64
b2xk
"""
)
new_msg = replace(msg, "old", "new")
# bmV3 is new base64 encoded
assert "bmV3" in new_msg.as_string()
assert "b2xk" not in new_msg.as_string()
def test_replace_multipart_alternative():
msg = email.message_from_string(
"""Content-Type: multipart/alternative;
boundary="foo"
Content-Transfer-Encoding: 7bit
Test-Header: Test-Value
--foo
Content-Transfer-Encoding: 7bit
Content-Type: text/plain; charset=us-ascii
old
--foo
Content-Transfer-Encoding: 7bit
Content-Type: text/html; charset=us-ascii
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii">
</head>
<body style="word-wrap: break-word;" class="">
<b class="">old</b>
</body>
</html>
"""
)
new_msg = replace(msg, "old", "new")
# headers are not affected
assert "Test-Header: Test-Value" in new_msg.as_string()
assert "new" in new_msg.as_string()
assert "old" not in new_msg.as_string()
def test_replace_str():
msg = "a string"
new_msg = replace(msg, "a", "still a")
assert new_msg == "still a string"
def test_generate_reply_email(flask_client):
user = create_new_user()
reply_email = generate_reply_email("test@example.org", user)
assert reply_email.endswith(EMAIL_DOMAIN)
reply_email = generate_reply_email("", user)
assert reply_email.endswith(EMAIL_DOMAIN)
def test_generate_reply_email_include_sender_in_reverse_alias(flask_client):
# user enables include_sender_in_reverse_alias
user = create_new_user()
user.include_sender_in_reverse_alias = True
reply_email = generate_reply_email("test@example.org", user)
assert reply_email.startswith("test_at_example_org")
assert reply_email.endswith(EMAIL_DOMAIN)
reply_email = generate_reply_email("", user)
assert reply_email.endswith(EMAIL_DOMAIN)
reply_email = generate_reply_email("👌汉字@example.org", user)
assert reply_email.startswith("yizi_at_example_org")
# make sure reply_email only contain lowercase
reply_email = generate_reply_email("TEST@example.org", user)
assert reply_email.startswith("test_at_example_org")
reply_email = generate_reply_email("test.dot@example.org", user)
assert reply_email.startswith("test_dot_at_example_org")
def test_normalize_reply_email(flask_client):
assert normalize_reply_email("re+abcd@sl.local") == "re+abcd@sl.local"
assert normalize_reply_email('re+"ab cd"@sl.local') == "re+_ab_cd_@sl.local"
def test_get_encoding():
msg = email.message_from_string("")
assert get_encoding(msg) == EmailEncoding.NO
msg = email.message_from_string("Content-TRANSFER-encoding: Invalid")
assert get_encoding(msg) == EmailEncoding.NO
msg = email.message_from_string("Content-TRANSFER-encoding: 7bit")
assert get_encoding(msg) == EmailEncoding.NO
msg = email.message_from_string("Content-TRANSFER-encoding: 8bit")
assert get_encoding(msg) == EmailEncoding.NO
msg = email.message_from_string("Content-TRANSFER-encoding: binary")
assert get_encoding(msg) == EmailEncoding.NO
msg = email.message_from_string("Content-TRANSFER-encoding: quoted-printable")
assert get_encoding(msg) == EmailEncoding.QUOTED
msg = email.message_from_string("Content-TRANSFER-encoding: base64")
assert get_encoding(msg) == EmailEncoding.BASE64
def test_encode_text():
assert encode_text("") == ""
assert encode_text("ascii") == "ascii"
assert encode_text("ascii", EmailEncoding.BASE64) == "YXNjaWk="
assert encode_text("ascii", EmailEncoding.QUOTED) == "ascii"
assert encode_text("mèo méo") == "mèo méo"
assert encode_text("mèo méo", EmailEncoding.BASE64) == "bcOobyBtw6lv"
assert encode_text("mèo méo", EmailEncoding.QUOTED) == "m=C3=A8o m=C3=A9o"
def test_decode_text():
assert decode_text("") == ""
assert decode_text("ascii") == "ascii"
assert (
decode_text(encode_text("ascii", EmailEncoding.BASE64), EmailEncoding.BASE64)
== "ascii"
)
assert (
decode_text(
encode_text("mèo méo 🇪🇺", EmailEncoding.BASE64), EmailEncoding.BASE64
)
== "mèo méo 🇪🇺"
)
assert (
decode_text(encode_text("ascii", EmailEncoding.QUOTED), EmailEncoding.QUOTED)
== "ascii"
)
assert (
decode_text(
encode_text("mèo méo 🇪🇺", EmailEncoding.QUOTED), EmailEncoding.QUOTED
)
== "mèo méo 🇪🇺"
)
def test_should_disable(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
assert not should_disable(alias)[0]
# create a lot of bounce on this alias
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
for _ in range(20):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
commit=True,
bounced=True,
)
assert should_disable(alias)[0]
# should not affect another alias
alias2 = Alias.create_new_random(user)
Session.commit()
assert not should_disable(alias2)[0]
def test_should_disable_bounces_every_day(flask_client):
"""if an alias has bounces every day at least 9 days in the last 10 days, disable alias"""
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
assert not should_disable(alias)[0]
# create a lot of bounce on this alias
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
for i in range(9):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
commit=True,
bounced=True,
created_at=arrow.now().shift(days=-i),
)
assert should_disable(alias)[0]
def test_should_disable_bounces_account(flask_client):
"""if an account has more than 10 bounces every day for at least 5 days in the last 10 days, disable alias"""
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
# create a lot of bounces on alias
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
for day in range(5):
for _ in range(11):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
commit=True,
bounced=True,
created_at=arrow.now().shift(days=-day),
)
alias2 = Alias.create_new_random(user)
assert should_disable(alias2)[0]
def test_should_disable_bounce_consecutive_days(flask_client):
user = login(flask_client)
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
# create 6 bounce on this alias in the last 24h: alias is not disabled
for _ in range(6):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
commit=True,
bounced=True,
)
assert not should_disable(alias)[0]
# create +10 bounces in the last 7 days: alias should be disabled
for _ in range(11):
EmailLog.create(
user_id=user.id,
contact_id=contact.id,
alias_id=contact.alias_id,
commit=True,
bounced=True,
created_at=arrow.now().shift(days=-3),
)
assert should_disable(alias)[0]
def test_parse_id_from_bounce():
assert parse_id_from_bounce("bounces+1234+@local") == 1234
assert parse_id_from_bounce("anything+1234+@local") == 1234
def test_get_queue_id():
msg = email.message_from_string(
"Received: from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)"
)
assert get_queue_id(msg) == "4FxQmw1DXdz2vK2"
def test_get_queue_id_from_double_header():
msg = load_eml_file("double_queue_id_header.eml")
assert get_queue_id(msg) == "6D8C13F069"
def test_should_ignore_bounce(flask_client):
assert not should_ignore_bounce("not-exist")
IgnoreBounceSender.create(mail_from="to-ignore@example.com")
assert should_ignore_bounce("to-ignore@example.com")
def test_get_header_unicode():
assert get_header_unicode("ab@cd.com") == "ab@cd.com"
assert get_header_unicode("=?utf-8?B?w6nDqQ==?=@example.com") == "éé@example.com"
def test_get_orig_message_from_bounce():
with open(os.path.join(ROOT_DIR, "local_data", "email_tests", "bounce.eml")) as f:
bounce_report = email.message_from_file(f)
orig_msg = get_orig_message_from_bounce(bounce_report)
assert orig_msg["X-SimpleLogin-Type"] == "Forward"
assert orig_msg["X-SimpleLogin-Envelope-From"] == "sender@gmail.com"
def test_get_mailbox_bounce_info():
with open(os.path.join(ROOT_DIR, "local_data", "email_tests", "bounce.eml")) as f:
bounce_report = email.message_from_file(f)
orig_msg = get_mailbox_bounce_info(bounce_report)
assert orig_msg["Final-Recipient"] == "rfc822; not-existing@gmail.com"
assert orig_msg["Original-Recipient"] == "rfc822;not-existing@gmail.com"
def test_is_invalid_mailbox_domain(flask_client):
domain = random_domain()
InvalidMailboxDomain.create(domain=domain, commit=True)
assert is_invalid_mailbox_domain(domain)
assert is_invalid_mailbox_domain(f"sub.{domain}")
assert is_invalid_mailbox_domain(f"sub1.sub2.{domain}")
assert not is_invalid_mailbox_domain("xy.zt")
@pytest.mark.parametrize("object_id", [10**i for i in range(0, 5)])
def test_generate_verp_email(object_id):
generated_email = generate_verp_email(
VerpType.bounce_forward, object_id, "somewhere.net"
)
info = get_verp_info_from_email(generated_email.lower())
assert info[0] == VerpType.bounce_forward
assert info[1] == object_id
def test_generate_verp_email_forward_reply_phase():
"""make sure the verp type is taken into account in verp generation"""
for phase in [
VerpType.bounce_forward,
VerpType.bounce_reply,
VerpType.transactional,
]:
verp = generate_verp_email(phase, 100)
verp_info = get_verp_info_from_email(verp)
assert verp_info[0] == phase
assert verp_info[1] == 100
def test_add_header_multipart_with_invalid_part():
msg = load_eml_file("multipart_alternative.eml")
parts = msg.get_payload() + ["invalid"]
msg.set_payload(parts)
msg = add_header(msg, "INJECT", "INJECT")
for i, part in enumerate(msg.get_payload()):
if i < 2:
assert part.get_payload().index("INJECT") > -1
else:
assert part == "invalid"
def test_sl_formataddr():
# when the name part (first element in the tuple) is empty, formataddr() returns a Header
# this makes sure sl_formataddr always returns str
assert sl_formataddr(("", "a@b.c")) == "a@b.c"
assert sl_formataddr(("é", "è@ç.à")) == "=?utf-8?b?w6k=?= <è@ç.à>"
# test that the same name-address can't be handled by the built-in formataddr
with pytest.raises(UnicodeEncodeError):
formataddr(("é", "è@ç.à"))

View File

@ -0,0 +1,109 @@
from http import HTTPStatus
from random import Random
from flask import g
from app import config
from app.extensions import limiter
from tests.conftest import app as test_app
from tests.utils import login
# IMPORTANT NOTICE
# ----------------
# This test file has a special behaviour. After each request, a call to fix_rate_limit_after_request must
# be performed, in order for the rate_limiting process to work appropriately in test time.
# If you want to see why, feel free to refer to the source of the "hack":
# https://github.com/alisaifee/flask-limiter/issues/147#issuecomment-642683820
_ENDPOINT = "/tests/internal/rate_limited"
_MAX_PER_MINUTE = 3
@test_app.route(
_ENDPOINT,
methods=["GET"],
)
@limiter.limit(f"{_MAX_PER_MINUTE}/minute")
def rate_limited_endpoint_1():
return "Working", HTTPStatus.OK
def random_ip() -> str:
rand = Random()
octets = [str(rand.randint(0, 255)) for _ in range(4)]
return ".".join(octets)
def fix_rate_limit_after_request():
g._rate_limiting_complete = False
def request_headers(source_ip: str) -> dict:
return {"X-Forwarded-For": source_ip}
def test_rate_limit_limits_by_source_ip(flask_client):
config.DISABLE_RATE_LIMIT = False
source_ip = random_ip()
for _ in range(_MAX_PER_MINUTE):
res = flask_client.get(_ENDPOINT, headers=request_headers(source_ip))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.OK
res = flask_client.get(_ENDPOINT, headers=request_headers(source_ip))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.TOO_MANY_REQUESTS
# Check that changing the "X-Forwarded-For" allows the request to succeed
res = flask_client.get(_ENDPOINT, headers=request_headers(random_ip()))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.OK
def test_rate_limit_limits_by_user_id(flask_client):
config.DISABLE_RATE_LIMIT = False
# Login with a user
login(flask_client)
fix_rate_limit_after_request()
# Run the N requests with a different source IP but with the same user
for _ in range(_MAX_PER_MINUTE):
res = flask_client.get(_ENDPOINT, headers=request_headers(random_ip()))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.OK
res = flask_client.get(_ENDPOINT, headers=request_headers(random_ip()))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.TOO_MANY_REQUESTS
def test_rate_limit_limits_by_user_id_ignoring_ip(flask_client):
config.DISABLE_RATE_LIMIT = False
source_ip = random_ip()
# Login with a user
login(flask_client)
fix_rate_limit_after_request()
# Run the N requests with a different source IP but with the same user
for _ in range(_MAX_PER_MINUTE):
res = flask_client.get(_ENDPOINT, headers=request_headers(source_ip))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.OK
res = flask_client.get(_ENDPOINT)
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.TOO_MANY_REQUESTS
# Log out
flask_client.cookie_jar.clear()
# Log in with another user
login(flask_client)
fix_rate_limit_after_request()
# Run the request again, reusing the same IP as before
res = flask_client.get(_ENDPOINT, headers=request_headers(source_ip))
fix_rate_limit_after_request()
assert res.status_code == HTTPStatus.OK

View File

@ -0,0 +1,47 @@
from app.image_validation import ImageFormat, detect_image_format
from pathlib import Path
def get_path_to_static_dir() -> Path:
this_path = Path(__file__)
repo_root_path = this_path.parent.parent
return repo_root_path.joinpath("static")
def read_static_file_contents(filename: str) -> bytes:
image_path = get_path_to_static_dir().joinpath(filename)
with open(image_path.as_posix(), "rb") as f:
return f.read()
def read_test_data_file_contents(filename: str) -> bytes:
this_path = Path(__file__)
test_data_path = this_path.parent.joinpath("data")
file_path = test_data_path.joinpath(filename)
with open(file_path.as_posix(), "rb") as f:
return f.read()
def test_non_image_file_returns_unknown():
contents = read_static_file_contents("local-storage-polyfill.js")
assert detect_image_format(contents) is ImageFormat.Unknown
def test_png_file_is_detected():
contents = read_static_file_contents("logo.png")
assert detect_image_format(contents) is ImageFormat.Png
def test_jpg_file_is_detected():
contents = read_test_data_file_contents("1px.jpg")
assert detect_image_format(contents) is ImageFormat.Jpg
def test_webp_file_is_detected():
contents = read_test_data_file_contents("1px.webp")
assert detect_image_format(contents) is ImageFormat.Webp
def test_svg_file_is_not_detected():
contents = read_static_file_contents("icon.svg")
assert detect_image_format(contents) is ImageFormat.Unknown

View File

@ -0,0 +1,26 @@
from app.db import Session
from app.jose_utils import make_id_token, verify_id_token
from app.models import ClientUser, Client
from tests.utils import create_new_user
def test_encode_decode(flask_client):
user = create_new_user()
client1 = Client.create_new(name="Demo", user_id=user.id)
client1.oauth_client_id = "client-id"
client1.oauth_client_secret = "client-secret"
Session.commit()
client_user = ClientUser.create(client_id=client1.id, user_id=user.id)
Session.commit()
jwt_token = make_id_token(client_user)
assert type(jwt_token) is str
assert verify_id_token(jwt_token)
def test_db_tear_down(flask_client):
"""make sure the db is reset after each test"""
assert len(ClientUser.filter_by().all()) == 0

View File

@ -0,0 +1,191 @@
import os
import tempfile
import threading
import socket
from email.message import Message
from random import random
from typing import Callable
import pytest
from aiosmtpd.controller import Controller
from app.email import headers
from app.mail_sender import (
mail_sender,
SendRequest,
load_unsent_mails_from_fs_and_resend,
)
from app import config
def create_dummy_send_request() -> SendRequest:
to_addr = f"to-{int(random())}@destination.com"
from_addr = f"from-{int(random())}@source.com"
msg = Message()
msg[headers.TO] = to_addr
msg[headers.FROM] = from_addr
msg[headers.SUBJECT] = f"Random subject {random()}"
msg.set_payload(f"Test content {random()}")
return SendRequest(
f"from-{int(random())}@envelope.com",
to_addr,
msg,
)
@mail_sender.store_emails_test_decorator
def test_mail_sender_save_to_mem():
send_request = create_dummy_send_request()
mail_sender.send(send_request, 0)
stored_emails = mail_sender.get_stored_emails()
assert len(stored_emails) == 1
assert stored_emails[0] == send_request
def close_on_connect_dummy_server() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("localhost", 0))
sock.listen()
port = sock.getsockname()[1]
def close_on_accept():
connection, _ = sock.accept()
connection.close()
sock.close()
threading.Thread(target=close_on_accept, daemon=True).start()
return port
def closed_dummy_server() -> int:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("localhost", 0))
sock.listen()
port = sock.getsockname()[1]
sock.close()
return port
def smtp_response_server(smtp_response: str) -> Callable[[], int]:
def inner():
empty_port = closed_dummy_server()
class ResponseHandler:
async def handle_DATA(self, server, session, envelope) -> str:
return smtp_response
controller = Controller(
ResponseHandler(), hostname="localhost", port=empty_port
)
controller.start()
return controller.server.sockets[0].getsockname()[1]
return inner
def compare_send_requests(expected: SendRequest, request: SendRequest):
assert request.mail_options == expected.mail_options
assert request.rcpt_options == expected.rcpt_options
assert request.envelope_to == expected.envelope_to
assert request.envelope_from == expected.envelope_from
assert request.msg[headers.TO] == expected.msg[headers.TO]
assert request.msg[headers.FROM] == expected.msg[headers.FROM]
@pytest.mark.parametrize(
"server_fn",
[
close_on_connect_dummy_server,
closed_dummy_server,
smtp_response_server("421 Retry"),
smtp_response_server("500 error"),
],
)
def test_mail_sender_save_unsent_to_disk(server_fn):
original_postfix_server = config.POSTFIX_SERVER
config.POSTFIX_SERVER = "localhost"
config.NOT_SEND_EMAIL = False
config.POSTFIX_SUBMISSION_TLS = False
config.POSTFIX_PORT = server_fn()
try:
with tempfile.TemporaryDirectory() as temp_dir:
config.SAVE_UNSENT_DIR = temp_dir
send_request = create_dummy_send_request()
assert not mail_sender.send(send_request, 0)
found_files = os.listdir(temp_dir)
assert len(found_files) == 1
loaded_send_request = SendRequest.load_from_file(
os.path.join(temp_dir, found_files[0])
)
compare_send_requests(loaded_send_request, send_request)
finally:
config.POSTFIX_SERVER = original_postfix_server
config.NOT_SEND_EMAIL = True
@mail_sender.store_emails_test_decorator
def test_send_unsent_email_from_fs():
original_postfix_server = config.POSTFIX_SERVER
config.POSTFIX_SERVER = "localhost"
config.NOT_SEND_EMAIL = False
with tempfile.TemporaryDirectory() as temp_dir:
try:
config.SAVE_UNSENT_DIR = temp_dir
send_request = create_dummy_send_request()
assert not mail_sender.send(send_request, 1)
finally:
config.POSTFIX_SERVER = original_postfix_server
config.NOT_SEND_EMAIL = True
saved_files = os.listdir(config.SAVE_UNSENT_DIR)
assert len(saved_files) == 1
mail_sender.purge_stored_emails()
load_unsent_mails_from_fs_and_resend()
sent_emails = mail_sender.get_stored_emails()
assert len(sent_emails) == 1
compare_send_requests(send_request, sent_emails[0])
assert sent_emails[0].ignore_smtp_errors
assert not os.path.exists(os.path.join(config.SAVE_UNSENT_DIR, saved_files[0]))
saved_files = os.listdir(config.SAVE_UNSENT_DIR)
assert len(saved_files) == 0
@mail_sender.store_emails_test_decorator
def test_failed_resend_does_not_delete_file():
original_postfix_server = config.POSTFIX_SERVER
config.POSTFIX_SERVER = "localhost"
config.NOT_SEND_EMAIL = False
try:
with tempfile.TemporaryDirectory() as temp_dir:
config.SAVE_UNSENT_DIR = temp_dir
send_request = create_dummy_send_request()
# Send and store email in disk
assert not mail_sender.send(send_request, 1)
saved_files = os.listdir(config.SAVE_UNSENT_DIR)
assert len(saved_files) == 1
mail_sender.purge_stored_emails()
# Send and keep email in disk
load_unsent_mails_from_fs_and_resend()
sent_emails = mail_sender.get_stored_emails()
assert len(sent_emails) == 1
compare_send_requests(send_request, sent_emails[0])
assert sent_emails[0].ignore_smtp_errors
assert os.path.exists(os.path.join(config.SAVE_UNSENT_DIR, saved_files[0]))
# No more emails are stored in disk
assert saved_files == os.listdir(config.SAVE_UNSENT_DIR)
finally:
config.POSTFIX_SERVER = original_postfix_server
config.NOT_SEND_EMAIL = True
@mail_sender.store_emails_test_decorator
def test_ok_mail_does_not_generate_unsent_file():
with tempfile.TemporaryDirectory() as temp_dir:
config.SAVE_UNSENT_DIR = temp_dir
send_request = create_dummy_send_request()
# Send and store email in disk
assert mail_sender.send(send_request, 1)
saved_files = os.listdir(config.SAVE_UNSENT_DIR)
assert len(saved_files) == 0

View File

@ -0,0 +1,35 @@
import email
from app.email_utils import (
copy,
)
from app.message_utils import message_to_bytes
def test_copy():
email_str = """
From: abcd@gmail.com
To: hey@example.org
Subject: subject
Body
"""
msg = email.message_from_string(email_str)
msg2 = copy(msg)
assert message_to_bytes(msg) == message_to_bytes(msg2)
msg = email.message_from_string("👌")
msg2 = copy(msg)
assert message_to_bytes(msg) == message_to_bytes(msg2)
def test_to_bytes():
msg = email.message_from_string("☕️ emoji")
assert message_to_bytes(msg)
# \n is appended when message is converted to bytes
assert message_to_bytes(msg).decode() == "\n☕️ emoji"
msg = email.message_from_string("ascii")
assert message_to_bytes(msg) == b"\nascii"
msg = email.message_from_string("éèà€")
assert message_to_bytes(msg).decode() == "\néèà€"

317
app/tests/test_models.py Normal file
View File

@ -0,0 +1,317 @@
import random
from uuid import UUID
import arrow
import pytest
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, NOREPLY
from app.db import Session
from app.email_utils import parse_full_address, generate_reply_email
from app.models import (
generate_email,
Alias,
Contact,
Mailbox,
SenderFormatEnum,
EnumE,
Subscription,
PlanEnum,
PADDLE_SUBSCRIPTION_GRACE_DAYS,
)
from tests.utils import login, create_new_user, random_token
def test_generate_email(flask_client):
email = generate_email()
assert email.endswith("@" + EMAIL_DOMAIN)
with pytest.raises(ValueError):
UUID(email.split("@")[0], version=4)
email_uuid = generate_email(scheme=2)
assert UUID(email_uuid.split("@")[0], version=4)
def test_profile_picture_url(flask_client):
user = create_new_user()
assert user.profile_picture_url() == "http://sl.test/static/default-avatar.png"
def test_suggested_emails_for_user_who_cannot_create_new_alias(flask_client):
# make sure user is not in trial
user = create_new_user()
user.trial_end = None
# make sure user runs out of quota to create new email
for _ in range(MAX_NB_EMAIL_FREE_PLAN):
Alias.create_new(user=user, prefix="test")
Session.commit()
suggested_email, other_emails = user.suggested_emails(website_name="test")
# the suggested email is chosen from existing Alias
assert Alias.get_by(email=suggested_email)
# all other emails are generated emails
for email in other_emails:
assert Alias.get_by(email=email)
def test_alias_create_random(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
assert alias.email.endswith(EMAIL_DOMAIN)
def test_website_send_to(flask_client):
user = create_new_user()
alias = Alias.create_new_random(user)
Session.commit()
prefix = random_token()
# non-empty name
c1 = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
)
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@SL>'
# empty name, ascii website_from, easy case
c1.name = None
c1.website_from = f"First Last <{prefix}@example.com>"
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@SL>'
# empty name, RFC 2047 website_from
c1.name = None
c1.website_from = f"=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <{prefix}@example.com>"
assert c1.website_send_to() == f'"Nhơn Nguyễn | {prefix} at example.com" <rep@SL>'
def test_new_addr_default_sender_format(flask_client):
user = login(flask_client)
alias = Alias.first()
prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
commit=True,
)
assert contact.new_addr() == f'"First Last - {prefix} at example.com" <rep@SL>'
# Make sure email isn't duplicated if sender name equals email
contact.name = f"{prefix}@example.com"
assert contact.new_addr() == f'"{prefix} at example.com" <rep@SL>'
def test_new_addr_a_sender_format(flask_client):
user = login(flask_client)
user.sender_format = SenderFormatEnum.A.value
Session.commit()
alias = Alias.first()
prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
commit=True,
)
assert contact.new_addr() == f'"First Last - {prefix}(a)example.com" <rep@SL>'
def test_new_addr_no_name_sender_format(flask_client):
user = login(flask_client)
user.sender_format = SenderFormatEnum.NO_NAME.value
Session.commit()
alias = Alias.first()
prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
commit=True,
)
assert contact.new_addr() == "rep@SL"
def test_new_addr_name_only_sender_format(flask_client):
user = login(flask_client)
user.sender_format = SenderFormatEnum.NAME_ONLY.value
Session.commit()
alias = Alias.first()
prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
commit=True,
)
assert contact.new_addr() == "First Last <rep@SL>"
def test_new_addr_at_only_sender_format(flask_client):
user = login(flask_client)
user.sender_format = SenderFormatEnum.AT_ONLY.value
Session.commit()
alias = Alias.first()
prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
name="First Last",
commit=True,
)
assert contact.new_addr() == f'"{prefix} at example.com" <rep@SL>'
def test_new_addr_unicode(flask_client):
user = login(flask_client)
alias = Alias.first()
random_prefix = random_token()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{random_prefix}@example.com",
reply_email="rep@SL",
name="Nhơn Nguyễn",
commit=True,
)
assert (
contact.new_addr()
== f"=?utf-8?q?Nh=C6=A1n_Nguy=E1=BB=85n_-_{random_prefix}_at_example=2Ecom?= <rep@SL>"
)
# sanity check
assert parse_full_address(contact.new_addr()) == (
f"Nhơn Nguyễn - {random_prefix} at example.com",
"rep@sl",
)
def test_mailbox_delete(flask_client):
user = create_new_user()
m1 = Mailbox.create(
user_id=user.id, email="m1@example.com", verified=True, commit=True
)
m2 = Mailbox.create(
user_id=user.id, email="m2@example.com", verified=True, commit=True
)
m3 = Mailbox.create(
user_id=user.id, email="m3@example.com", verified=True, commit=True
)
# alias has 2 mailboxes
alias = Alias.create_new(user, "prefix", mailbox_id=m1.id)
Session.commit()
alias._mailboxes.append(m2)
alias._mailboxes.append(m3)
Session.commit()
assert len(alias.mailboxes) == 3
# delete m1, should not delete alias
Mailbox.delete(m1.id)
alias = Alias.get(alias.id)
assert len(alias.mailboxes) == 2
def test_EnumE():
class E(EnumE):
A = 100
B = 200
assert E.has_value(100)
assert not E.has_value(101)
assert E.get_name(100) == "A"
assert E.get_name(200) == "B"
assert E.get_name(101) is None
assert E.has_name("A")
assert not E.has_name("Not existent")
assert E.get_value("A") == 100
assert E.get_value("Not existent") is None
def test_can_create_new_alias_disabled_user():
user = create_new_user()
assert user.can_create_new_alias()
user.disabled = True
assert not user.can_create_new_alias()
def test_user_get_subscription_grace_period(flask_client):
user = create_new_user()
sub = Subscription.create(
user_id=user.id,
cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
update_url="https://checkout.paddle.com/subscription/update?user=1234",
subscription_id=str(random.random()),
event_time=arrow.now(),
next_bill_date=arrow.now().shift(days=-PADDLE_SUBSCRIPTION_GRACE_DAYS).date(),
plan=PlanEnum.monthly,
commit=True,
)
assert user.get_paddle_subscription() is not None
sub.next_bill_date = (
arrow.now().shift(days=-(PADDLE_SUBSCRIPTION_GRACE_DAYS + 1)).date()
)
assert user.get_paddle_subscription() is None
def test_create_contact_for_noreply(flask_client):
user = create_new_user()
alias = Alias.filter(Alias.user_id == user.id).first()
# create a contact with NOREPLY as reply_email
Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=f"{random.random()}@contact.test",
reply_email=NOREPLY,
commit=True,
)
# create a contact for NOREPLY shouldn't raise CannotCreateContactForReverseAlias
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=NOREPLY,
reply_email=generate_reply_email(NOREPLY, user),
)
assert contact.website_email == NOREPLY

View File

@ -0,0 +1,17 @@
from monitoring import _process_ps_output
def test_monitoring_proc_count():
data = """
PID TTY STAT TIME COMMAND
1432 ? S< 0:00 [loop44]
3438 ? Ssl 0:00 /bin/sh arg
3440 ? Sl 0:00 /bin/cron args
3440 ? Sl 0:00 smtp arg
3448 ? Sl 0:00 smtpd arg
3441 ? Sl 0:00 other smtpd arg
"""
result = _process_ps_output(["smtp", "smtpd", "cron"], data)
assert 1 == result["smtp"]
assert 1 == result["smtpd"]
assert 0 == result["cron"]

View File

@ -0,0 +1,79 @@
import flask
import pytest
from app.oauth_models import (
get_scopes,
Scope,
get_response_types,
ResponseType,
response_types_to_str,
get_response_types_from_str,
)
def test_get_scopes(flask_app):
with flask_app.test_request_context("/"):
scopes = get_scopes(flask.request)
assert scopes == set()
with flask_app.test_request_context("/?scope=email&scope=name"):
scopes = get_scopes(flask.request)
assert scopes == {Scope.NAME, Scope.EMAIL}
# a space between email and name
with flask_app.test_request_context("/?scope=email%20name"):
scopes = get_scopes(flask.request)
assert scopes == {Scope.NAME, Scope.EMAIL}
# a comma between email and name
with flask_app.test_request_context("/?scope=email,name"):
scopes = get_scopes(flask.request)
assert scopes == {Scope.NAME, Scope.EMAIL}
# non-existent scope: raise ValueError
with flask_app.test_request_context("/?scope=abcd"):
with pytest.raises(ValueError):
get_scopes(flask.request)
def test_get_response_types(flask_app):
with flask_app.test_request_context("/"):
response_types = get_response_types(flask.request)
assert response_types == set()
with flask_app.test_request_context("/?response_type=token&response_type=id_token"):
response_types = get_response_types(flask.request)
assert response_types == {ResponseType.TOKEN, ResponseType.ID_TOKEN}
# a space as separator
with flask_app.test_request_context("/?response_type=token%20id_token"):
response_types = get_response_types(flask.request)
assert response_types == {ResponseType.TOKEN, ResponseType.ID_TOKEN}
# a comma as separator
with flask_app.test_request_context("/?response_type=id_token,token"):
response_types = get_response_types(flask.request)
assert response_types == {ResponseType.TOKEN, ResponseType.ID_TOKEN}
# non-existent response_type: raise ValueError
with flask_app.test_request_context("/?response_type=abcd"):
with pytest.raises(ValueError):
get_response_types(flask.request)
def test_response_types_to_str():
assert response_types_to_str([]) == ""
assert response_types_to_str([ResponseType.CODE]) == "code"
assert (
response_types_to_str([ResponseType.CODE, ResponseType.ID_TOKEN])
== "code,id_token"
)
def test_get_response_types_from_str():
assert get_response_types_from_str("") == set()
assert get_response_types_from_str("token") == {ResponseType.TOKEN}
assert get_response_types_from_str("token id_token") == {
ResponseType.TOKEN,
ResponseType.ID_TOKEN,
}

View File

@ -0,0 +1,15 @@
from http import HTTPStatus
from app.onboarding.utils import CHROME_EXTENSION_LINK
CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"
def test_extension_redirect_is_working(flask_client):
res = flask_client.get(
"/onboarding/extension_redirect", headers={"User-Agent": CHROME_USER_AGENT}
)
assert res.status_code == HTTPStatus.FOUND
location_header = res.headers.get("Location")
assert location_header == CHROME_EXTENSION_LINK

View File

@ -0,0 +1,33 @@
import arrow
from app import paddle_callback
from app.db import Session
from app.mail_sender import mail_sender
from app.models import Subscription, PlanEnum
from tests.utils import create_new_user, random_token
@mail_sender.store_emails_test_decorator
def test_failed_payments():
user = create_new_user()
paddle_sub_id = random_token()
sub = Subscription.create(
user_id=user.id,
cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
update_url="https://checkout.paddle.com/subscription/update?user=1234",
subscription_id=paddle_sub_id,
event_time=arrow.now(),
next_bill_date=arrow.now().shift(days=10).date(),
plan=PlanEnum.monthly,
commit=True,
)
Session.commit()
paddle_callback.failed_payment(sub, paddle_sub_id)
sub = Subscription.get_by(subscription_id=paddle_sub_id)
assert sub.cancelled
assert 1 == len(mail_sender.get_stored_emails())
mail_sent = mail_sender.get_stored_emails()[0]
assert mail_sent.envelope_to == user.email

View File

@ -0,0 +1,45 @@
from app.paddle_utils import verify_incoming_request
def test_verify_incoming_request():
# the request comes from Paddle simulation
request_data = {
"alert_id": "1647146853",
"alert_name": "payment_succeeded",
"balance_currency": "EUR",
"balance_earnings": "966.81",
"balance_fee": "16.03",
"balance_gross": "107.37",
"balance_tax": "670.85",
"checkout_id": "8-a367127c071e8a2-cba0a50da3",
"country": "AU",
"coupon": "Coupon 7",
"currency": "USD",
"customer_name": "customer_name",
"earnings": "820.91",
"email": "awyman@example.org",
"event_time": "2019-12-14 18:43:09",
"fee": "0.26",
"ip": "65.220.94.158",
"marketing_consent": "1",
"order_id": "8",
"passthrough": "Example String",
"payment_method": "paypal",
"payment_tax": "0.18",
"product_id": "3",
"product_name": "Example String",
"quantity": "29",
"receipt_url": "https://my.paddle.com/receipt/4/5854e29100fd226-440fa7ba7a",
"sale_gross": "568.82",
"used_price_override": "true",
"p_signature": "CQrBWKnAuhBOWdgu6+upbgpLo38c2oQJVgNHLTNsQoaUHtJgHUXzfUfQdcnD9q3EWZuQtyFXXPkygxx/fMbcu+UTnfxkjyecoHio8w4T858jU4VOy1RPqYy6fqazG1vlngiuYqEdgo8OHT/6oIJAf+NWm1v1iwbpr62rDygzJWZrqTzVSKkESfW8/4goxlN2BWr6eaN/4nKQ4gaHq5ee3/7vMmkrLAQG509x9SK3H0bYvh3pvbWMUhYNz8j+7GZRlXcSCpMKw1nkO/jK4IXKW0rtSwgyVjJhpX+/rt2byaCmWEvP0LtGhrug9xAqMYJ3tDCJmwSk2cXG8rPE7oeBwEEElZrQJdbV+i6Tw5rw9LaqEGrjhSkOapfpINdct5UpKXybIyiRZZ111yhJL081T1rtBqb8L+wsPnHG8GzI1Fg5je98j5aXGQU9hcw5nQN779IJQWNN+GbDQZ+Eleu5c6ZYauxpKzE8s/Vs2a4/70KB6WBK6NKxNSIIoOTumKqnfEiPN0pxZp5MMi2dRW7wu7VqvcLbIEYtCkOLnjxVyko32B6AMIgn8CuHvQp9ScPdNdU6B8dBXhdVfV75iYSwx+ythun5d3f357IecaZep27QQmKR/b7/pv4iMOiHKmFQRz9EKwqQm/3Xg2WS4GA4t1X0nslXMuEeRnX6xTaxbvk=",
}
assert verify_incoming_request(request_data)
# add a new field in request_data -> verify should fail
request_data["new_field"] = "new_field"
assert not verify_incoming_request(request_data)
# modify existing field -> verify should fail
request_data["sale_gross"] = "1.23"
assert not verify_incoming_request(request_data)

View File

@ -0,0 +1,65 @@
import os
from io import BytesIO
import pgpy
from pgpy import PGPMessage
from app.config import ROOT_DIR
from app.pgp_utils import (
load_public_key,
gpg,
encrypt_file,
encrypt_file_with_pgpy,
sign_data,
sign_data_with_pgpy,
)
def test_load_public_key():
public_key_path = os.path.join(ROOT_DIR, "local_data/public-pgp.asc")
public_key = open(public_key_path).read()
load_public_key(public_key)
assert len(gpg.list_keys()) == 1
def test_encrypt():
public_key_path = os.path.join(ROOT_DIR, "local_data/public-pgp.asc")
public_key = open(public_key_path).read()
fingerprint = load_public_key(public_key)
secret = encrypt_file(BytesIO(b"abcd"), fingerprint)
assert secret != ""
def test_encrypt_file_with_pgpy():
encrypt_decrypt_text("heyhey")
encrypt_decrypt_text("👍💪")
encrypt_decrypt_text("éèù")
encrypt_decrypt_text("片仮名")
def encrypt_decrypt_text(text: str):
public_key_path = os.path.join(ROOT_DIR, "local_data/public-pgp.asc")
public_key = open(public_key_path).read()
encrypted: PGPMessage = encrypt_file_with_pgpy(text.encode(), public_key)
# decrypt
private_key_path = os.path.join(ROOT_DIR, "local_data/private-pgp.asc")
private_key = open(private_key_path).read()
priv = pgpy.PGPKey()
priv.parse(private_key)
decrypted = priv.decrypt(encrypted).message
if type(decrypted) == str:
assert decrypted == text
elif type(decrypted) == bytearray:
assert decrypted.decode() == text
def test_sign_data():
assert sign_data("heyhey")
assert sign_data(b"bytes")
def test_sign_data_with_pgpy():
assert sign_data_with_pgpy("unicode")
assert sign_data_with_pgpy(b"bytes")

View File

@ -0,0 +1,42 @@
import threading
import time
from typing import Optional
import werkzeug.exceptions
from flask_login import login_user
from app.parallel_limiter import _InnerLock
from tests.utils import create_new_user
def test_parallel_limiter(flask_app):
user = create_new_user()
with flask_app.test_request_context():
login_user(user)
pl = _InnerLock("test", max_wait_secs=1)
for loop_id in range(10):
assert pl(lambda x: x)(loop_id) == loop_id
def sleep_thread(pl: _InnerLock, sem: Optional[threading.Semaphore] = None):
if sem is not None:
sem.release()
pl(time.sleep)(1)
def test_too_many_requests(flask_app):
user = create_new_user()
with flask_app.test_request_context():
login_user(user)
sem = threading.Semaphore(0)
pl = _InnerLock("test", max_wait_secs=5)
t = threading.Thread(target=sleep_thread, args=(pl, sem))
t.daemon = True
t.start()
sem.acquire()
try:
got_exception = False
pl(sleep_thread)(pl)
except werkzeug.exceptions.TooManyRequests:
got_exception = True
assert got_exception

Some files were not shown because too many files have changed in this diff Show More