Skip to content

Commit 22d3ac4

Browse files
authored
Mail mocking (#64)
* fix(auth): moved Redis code in authentication to common/redis.py * feat(mock): created MockMail object, no longer need external mail servers * feat(mock): patched register_verify_test
1 parent 2d2a3bc commit 22d3ac4

File tree

8 files changed

+88
-44
lines changed

8 files changed

+88
-44
lines changed

backend/app.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from common.plugins import jwt, mail
1010
from database.database import db
1111
from routes.auth import auth
12-
from routes.puzzle import puzzle
1312
from routes.leaderboard import leaderboard
13+
from routes.puzzle import puzzle
1414
from routes.user import user
1515

1616

@@ -28,7 +28,7 @@ def handle_exception(error):
2828
return response
2929

3030

31-
def create_app():
31+
def create_app(config={}):
3232
app = Flask(__name__)
3333
CORS(app)
3434

@@ -53,6 +53,9 @@ def create_app():
5353
app.config["MAIL_USE_TLS"] = True
5454
app.config["MAIL_USE_SSL"] = False
5555

56+
for key, value in config.items():
57+
app.config[key] = value
58+
5659
app.after_request(update_token)
5760

5861
# Initialise plugins

backend/common/redis.py

+24
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@
1010

1111
## EMAIL VERIFICATION
1212

13+
def add_verification(data, code):
14+
# We use a pipeline here to ensure these instructions are atomic
15+
pipeline = cache.pipeline()
16+
17+
pipeline.hset(f"register:{code}", mapping=data)
18+
pipeline.expire(f"register:{code}", timedelta(hours=1))
19+
20+
pipeline.execute()
21+
22+
def get_verification(code):
23+
key = f"register:{code}"
24+
25+
if not cache.exists(key):
26+
return None
27+
28+
result = {}
29+
30+
for key, value in cache.hgetall(key).items():
31+
result[key.decode()] = value.decode()
32+
33+
return result
34+
1335
## LOCKOUT
1436

1537
def register_incorrect(id):
@@ -46,5 +68,7 @@ def is_blocked(id):
4668
token = cache.get(f"block_{id}")
4769
return token is not None
4870

71+
## GENERAL FUNCTIONS
72+
4973
def clear_redis():
5074
cache.flushdb()

backend/models/user.py

+7-19
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from itsdangerous import URLSafeTimedSerializer
88

99
from common.exceptions import AuthError, InvalidError, RequestError
10-
from common.redis import cache
10+
from common.redis import add_verification, get_verification
1111
from database.user import add_user, email_exists, fetch_user, get_user_info, username_exists
1212

1313
hasher = PasswordHasher(
@@ -64,31 +64,19 @@ def register(email, username, password):
6464
"password": hashed
6565
}
6666

67-
# We use a pipeline here to ensure these instructions are atomic
68-
pipeline = cache.pipeline()
69-
70-
pipeline.hset(f"register:{code}", mapping=data)
71-
pipeline.expire(f"register:{code}", timedelta(hours=1))
72-
73-
pipeline.execute()
67+
add_verification(data, code)
7468

7569
return code
7670

7771
@staticmethod
78-
def register_verify(token):
79-
cache_key = f"register:{token}"
72+
def register_verify(code):
73+
result = get_verification(code)
8074

81-
if not cache.exists(cache_key):
75+
if result is None:
8276
raise AuthError("Token expired or does not correspond to registering user")
8377

84-
result = cache.hgetall(cache_key)
85-
stringified = {}
86-
87-
for key, value in result.items():
88-
stringified[key.decode()] = value.decode()
89-
90-
id = add_user(stringified["email"], stringified["username"], stringified["password"])
91-
return User(id, stringified["email"], stringified["username"], stringified["password"])
78+
id = add_user(result["email"], result["username"], result["password"])
79+
return User(id, result["email"], result["username"], result["password"])
9280

9381
@staticmethod
9482
def login(email, password):

backend/test/auth/register_test.py

+9-11
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import email
21
import os
3-
import poplib
42
import requests
53

64
# Imports for pytest
5+
from pytest_mock import mocker
6+
77
from test.helpers import clear_all, db_add_user
88
from test.fixtures import app, client
9+
from test.mock.mock_mail import mailbox
910

1011

1112
def register(json):
@@ -64,15 +65,13 @@ def test_duplicate_username(client):
6465
assert response.status_code == 400
6566

6667

67-
def test_success(client):
68+
def test_register_success(client, mocker):
69+
mocker.patch("routes.auth.mail", mailbox)
70+
6871
clear_all()
6972

7073
# Check that we get an email sent
71-
mailbox = poplib.POP3("pop3.mailtrap.io", 1100)
72-
mailbox.user(os.environ["MAILTRAP_USERNAME"])
73-
mailbox.pass_(os.environ["MAILTRAP_PASSWORD"])
74-
75-
(before, _) = mailbox.stat()
74+
before = len(mailbox.messages)
7675

7776
# Register normally
7877
response = client.post("/auth/register", json={
@@ -84,12 +83,11 @@ def test_success(client):
8483
assert response.status_code == 200
8584

8685
# Check that an email was in fact sent
87-
(after, _) = mailbox.stat()
86+
after = len(mailbox.messages)
8887

8988
assert after == before + 1
9089

9190
# Verify recipient
92-
raw_email = b"\n".join(mailbox.retr(1)[1])
93-
parsed_email = email.message_from_bytes(raw_email)
91+
parsed_email = mailbox.get_message(-1)
9492

9593
assert parsed_email["To"] == "[email protected]"

backend/test/auth/register_verify_test.py

+7-8
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import common
1414
from test.helpers import clear_all
1515
from test.fixtures import app, client
16+
from test.mock.mock_mail import mailbox
1617

1718
## HELPER FUNCTIONS
1819

@@ -38,6 +39,8 @@ def test_invalid_token(client):
3839
# TODO: try working on this, if not feasible delete this test and test manually
3940
@pytest.mark.skip()
4041
def test_token_expired(client, mocker):
42+
clear_all()
43+
4144
fake = fakeredis.FakeStrictRedis()
4245
mocker.patch.object(common.redis, "cache", return_value=fake)
4346

@@ -79,7 +82,9 @@ def test_token_expired(client, mocker):
7982

8083
assert response.status_code == 401
8184

82-
def test_success(client):
85+
def test_verify_success(client, mocker):
86+
mocker.patch("routes.auth.mail", mailbox)
87+
8388
clear_all()
8489

8590
register_response = client.post("/auth/register", json={
@@ -91,13 +96,7 @@ def test_success(client):
9196
assert register_response.status_code == 200
9297

9398
# Check inbox
94-
mailbox = poplib.POP3("pop3.mailtrap.io", 1100)
95-
mailbox.user(os.environ["MAILTRAP_USERNAME"])
96-
mailbox.pass_(os.environ["MAILTRAP_PASSWORD"])
97-
98-
# Check the contents of the email, and harvest the token from there
99-
raw_email = b"\n".join(mailbox.retr(1)[1])
100-
parsed_email = email.message_from_bytes(raw_email)
99+
parsed_email = mailbox.get_message(-1)
101100

102101
# Assuming there's a HTML part
103102
for part in parsed_email.walk():

backend/test/fixtures.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
from app import create_app
2+
13
import pytest
4+
from pytest_mock import mocker
25

3-
from app import create_app
6+
from test.mock.mock_mail import mailbox
47

58
@pytest.fixture()
6-
def app():
7-
app = create_app()
8-
app.config["TESTING"] = True
9+
def app(mocker):
10+
# Mock only where the data is being used
11+
mocker.patch("app.mail", mailbox)
12+
mocker.patch("common.plugins.mail", mailbox)
913

14+
app = create_app({"TESTING": True})
1015
yield app
1116

1217
@pytest.fixture()

backend/test/helpers.py

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from database.puzzle import add_part, add_question, add_competition
55
from models.user import User
66

7+
## DATABASE FUNCTIONS
78

89
def db_add_competition(name):
910
return add_competition(name)
@@ -24,6 +25,8 @@ def clear_all():
2425
# Clear database
2526
clear_database()
2627

28+
## HEADER FUNCTIONS
29+
2730
def get_cookie_from_header(response, cookie_name):
2831
cookie_headers = response.headers.getlist("Set-Cookie")
2932

@@ -44,3 +47,8 @@ def get_cookie_from_header(response, cookie_name):
4447
def generate_csrf_header(response):
4548
csrf_token = get_cookie_from_header(response, "csrf_access_token")["csrf_access_token"]
4649
return {"X-CSRF-TOKEN": csrf_token}
50+
51+
## EMAIL MOCKING
52+
53+
def get_emails():
54+
pass

backend/test/mock/mock_mail.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import email
2+
import flask_mail
3+
4+
class MockMail:
5+
def __init__(self):
6+
self.ascii_attachments = False
7+
self.messages = []
8+
9+
def init_app(self, app):
10+
app.extensions = getattr(app, 'extensions', {})
11+
app.extensions['mail'] = self
12+
13+
def send(self, message: flask_mail.Message):
14+
self.messages.append(message.as_bytes())
15+
16+
def get_message(self, n):
17+
return email.message_from_bytes(self.messages[n])
18+
19+
mailbox = MockMail()

0 commit comments

Comments
 (0)