Skip to content

Commit 317a6c7

Browse files
authored
Dom debug (#130)
* Fixed #127
1 parent 2e42e8c commit 317a6c7

File tree

9 files changed

+147
-14
lines changed

9 files changed

+147
-14
lines changed

ptmd/api/queries/users.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ptmd.config import session
1515
from ptmd.const import CREATE_USER_SCHEMA_PATH
1616
from ptmd.database import login_user, get_token, User, TokenBlocklist, Token, Organisation
17+
from ptmd.exceptions import PasswordPolicyError, TokenInvalidError, TokenExpiredError
1718
from .utils import check_role
1819

1920

@@ -76,11 +77,19 @@ def change_password() -> tuple[Response, int]:
7677
if new_password != repeat_password:
7778
return jsonify({"msg": "Passwords do not match"}), 400
7879

80+
if password == new_password:
81+
return jsonify({"msg": "New password cannot be the same as the old one"}), 400
82+
7983
user: User = User.query.filter(User.id == get_jwt()['sub']).first()
80-
changed: bool = user.change_password(old_password=password, new_password=new_password)
81-
if not changed:
82-
return jsonify({"msg": "Wrong password"}), 400
83-
return jsonify({"msg": "Password changed successfully"}), 200
84+
try:
85+
changed: bool = user.change_password(old_password=password, new_password=new_password)
86+
if not changed:
87+
return jsonify({"msg": "Wrong password"}), 400
88+
return jsonify({"msg": "Password changed successfully"}), 200 if changed else jsonify()
89+
except PasswordPolicyError as e:
90+
return jsonify({"msg": str(e)}), 400
91+
except Exception:
92+
return jsonify({"msg": "An unexpected error occurred"}), 500
8493

8594

8695
@check_role(role='disabled')
@@ -188,8 +197,10 @@ def reset_password(token: str) -> tuple[Response, int]:
188197
user.set_password(password)
189198
session.delete(reset_token_from_db) # type: ignore
190199
return jsonify({"msg": "Password changed successfully"}), 200
191-
except Exception as e:
200+
except (PasswordPolicyError, TokenInvalidError, TokenExpiredError) as e:
192201
return jsonify({"msg": str(e)}), 400
202+
except Exception:
203+
return jsonify({"msg": "An unexpected error occurred"}), 500
193204

194205

195206
@check_role(role='admin')

ptmd/database/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77

88
SQLALCHEMY_DATABASE_URI: str = DOT_ENV_CONFIG['SQLALCHEMY_DATABASE_URL']
99
SQLALCHEMY_SECRET_KEY: str = DOT_ENV_CONFIG['SQLALCHEMY_SECRET_KEY']
10+
PASSWORD_POLICY: str = "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,20}$"

ptmd/database/models/user.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
"""
33
from __future__ import annotations
44
from typing import Generator
5+
from re import match
56

67
from passlib.hash import bcrypt
78
from ptmd.config import Base, db, session
89
from ptmd.const import ROLES
10+
from ptmd.exceptions import PasswordPolicyError
11+
from ptmd.database.const import PASSWORD_POLICY
912
from ptmd.database.models.token import Token
1013
from ptmd.lib.email import send_validation_mail, send_validated_account_mail
1114

@@ -97,7 +100,11 @@ def set_password(self, password: str) -> None:
97100
""" Set the user password. Helper function to avoid code repetition.
98101
99102
:param password: the new password
103+
104+
:raises PasswordPolicyError: if the password does not match the password policy
100105
"""
106+
if not match(PASSWORD_POLICY, password):
107+
raise PasswordPolicyError()
101108
self.password = bcrypt.hash(password)
102109
session.commit()
103110

ptmd/database/queries/users.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ptmd.config import session
99
from ptmd.logger import LOGGER
10+
from ptmd.exceptions import TokenExpiredError, TokenInvalidError
1011
from ptmd.database.models import User, Token
1112

1213

@@ -52,7 +53,7 @@ def get_token(token: str) -> Token:
5253
"""
5354
token_from_db: Token = Token.query.filter(Token.token == token).first()
5455
if token_from_db is None:
55-
raise Exception("Invalid token")
56+
raise TokenInvalidError
5657
if token_from_db.expires_on < datetime.now(token_from_db.expires_on.tzinfo):
57-
raise Exception("Token expired")
58+
raise TokenExpiredError
5859
return token_from_db

ptmd/exceptions.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
""" Custom exceptions for the ptmd package """
2+
from __future__ import annotations
3+
from abc import ABC
4+
5+
6+
class APIError(Exception, ABC):
7+
""" Exception raised when an API error occurs. This is an abstract class, do not use directly. """
8+
9+
def __init__(self) -> None:
10+
""" Constructor, do not use """
11+
self.message: str | None = None
12+
raise SyntaxError("Cannot instantiate abstract class APIError")
13+
14+
def __str__(self) -> str:
15+
""" String representation of the exception """
16+
return self.message or ""
17+
18+
19+
class PasswordPolicyError(APIError):
20+
""" Exception raised when a password does not meet the password policy """
21+
22+
def __init__(self) -> None:
23+
""" Constructor """
24+
self.message: str = "Password must be between 8 and 20 characters long, contain at least one uppercase " \
25+
"letter, one lowercase letter, one number and one special character."
26+
27+
28+
class TokenExpiredError(APIError):
29+
""" Exception raised when a token is expired """
30+
31+
def __init__(self) -> None:
32+
""" Constructor """
33+
self.message: str = "Token expired"
34+
35+
36+
class TokenInvalidError(APIError):
37+
""" Exception raised when a token is invalid """
38+
39+
def __init__(self) -> None:
40+
""" Constructor """
41+
self.message: str = "Invalid token"

tests/test_api/test_queries/test_users.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sqlalchemy.exc import IntegrityError
77

88
from ptmd.api import app
9+
from ptmd.exceptions import PasswordPolicyError
910

1011

1112
HEADERS = {'Content-Type': 'application/json'}
@@ -110,6 +111,13 @@ def test_change_pwd(self, mock_user, mock_jwt, mock_session,
110111
self.assertEqual(created_user.json, {'msg': 'Passwords do not match'})
111112

112113
user_data['confirm_password'] = '1234'
114+
created_user = client.put('/api/users',
115+
headers={'Authorization': f'Bearer {123}', **HEADERS},
116+
data=dumps(user_data))
117+
self.assertEqual(created_user.json, {'msg': 'New password cannot be the same as the old one'})
118+
119+
user_data['confirm_password'] = '666'
120+
user_data['new_password'] = '666'
113121
mock_user.query.filter().first().change_password.return_value = False
114122
created_user = client.put('/api/users',
115123
headers={'Authorization': f'Bearer {123}', **HEADERS},
@@ -128,6 +136,23 @@ def test_change_pwd(self, mock_user, mock_jwt, mock_session,
128136
data=dumps(user_data))
129137
self.assertEqual(created_user.json, {'msg': 'You are not authorized to access this route'})
130138

139+
mock_user.query.filter().first().change_password.side_effect = PasswordPolicyError()
140+
mock_get_current_user().role = 'admin'
141+
created_user = client.put('/api/users',
142+
headers={'Authorization': f'Bearer {123}', **HEADERS},
143+
data=dumps(user_data))
144+
self.assertEqual(created_user.json, {'msg': "Password must be between 8 and 20 characters long, contain at "
145+
"least one uppercase letter, one lowercase letter, one number "
146+
"and one special character."})
147+
self.assertEqual(created_user.status_code, 400)
148+
149+
mock_user.query.filter().first().change_password = lambda x: x/0
150+
created_user = client.put('/api/users',
151+
headers={'Authorization': f'Bearer {123}', **HEADERS},
152+
data=dumps(user_data))
153+
self.assertEqual(created_user.json, {'msg': 'An unexpected error occurred'})
154+
self.assertEqual(created_user.status_code, 500)
155+
131156
@patch('ptmd.api.queries.users.User')
132157
@patch('ptmd.api.queries.users.get_jwt', return_value={'sub': 1})
133158
def test_get_me(self, mock_jwt, mock_user, mock_get_current_user, mock_verify_jwt, mock_verify_in_request):
@@ -258,13 +283,21 @@ def test_reset_password_failed(self, mock_get_current_user, mock_verify_jwt, moc
258283
@patch('ptmd.api.queries.users.get_token')
259284
def test_reset_password_error(self, mock_token,
260285
mock_get_current_user, mock_verify_jwt, mock_verify_in_request):
261-
mock_token.side_effect = Exception('test')
286+
mock_token.side_effect = PasswordPolicyError()
262287
headers = {'Authorization': f'Bearer {123}', **HEADERS}
263288
with app.test_client() as client:
264289
response = client.post('/api/users/reset/123', data=dumps({"password": "None"}), headers=headers)
265-
self.assertEqual(response.json, {"msg": "test"})
290+
self.assertEqual(response.json, {"msg": "Password must be between 8 and 20 characters long, contain at "
291+
"least one uppercase letter, one lowercase letter, one number "
292+
"and one special character."})
266293
self.assertEqual(response.status_code, 400)
267294

295+
mock_token.side_effect = Exception()
296+
with app.test_client() as client:
297+
response = client.post('/api/users/reset/123', data=dumps({"password": "None"}), headers=headers)
298+
self.assertEqual(response.json, {"msg": "An unexpected error occurred"})
299+
self.assertEqual(response.status_code, 500)
300+
268301
@patch('ptmd.api.queries.users.get_token')
269302
@patch('ptmd.api.queries.users.session')
270303
def test_reset_password_success(self, mock_session, mock_token,

tests/test_database/test_models/test_user.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import patch, mock_open
33

44
from ptmd.database import User, Organisation, File
5+
from ptmd.exceptions import PasswordPolicyError
56

67

78
@patch("builtins.open", mock_open(read_data="{'save_credentials_file': 'test'}"))
@@ -10,14 +11,14 @@ class TestUser(TestCase):
1011
@patch('ptmd.database.models.token.send_confirmation_mail', return_value=True)
1112
def test_user(self, mock_send_confirmation_mail):
1213
expected_user = {'files': [], 'id': None, 'organisation': None, 'username': 'test', 'role': 'disabled'}
13-
user = User(username='test', password='test', email='[email protected]')
14+
user = User(username='test', password='A!Str0ngPwd', email='[email protected]')
1415
self.assertEqual(dict(user), expected_user)
15-
self.assertTrue(user.validate_password('test'))
16+
self.assertTrue(user.validate_password('A!Str0ngPwd'))
1617

1718
with patch('ptmd.database.models.user.session') as mock_session:
18-
changed = user.change_password(old_password='test', new_password='test2')
19+
changed = user.change_password(old_password='A!Str0ngPwd', new_password='A!Str0ngPwd2')
1920
self.assertTrue(changed)
20-
changed = user.change_password(old_password='test', new_password='test2')
21+
changed = user.change_password(old_password='test', new_password='A!Str0ngPwd')
2122
self.assertFalse(changed)
2223

2324
with patch('ptmd.database.models.user.send_validation_mail') as mock_email:
@@ -94,3 +95,12 @@ def test_user_serialisation_with_organisation(self, mock_organisation, mock_orga
9495
files = dict(user)['files']
9596
self.assertIn(dict(file_1), files)
9697
self.assertIn(dict(file_2), files)
98+
99+
@patch('ptmd.database.models.user.session')
100+
def test_set_password_policy_failure(self, mock_session):
101+
user = User(username='test', password='test', email='[email protected]', role='admin')
102+
with self.assertRaises(PasswordPolicyError) as context:
103+
user.set_password('test')
104+
self.assertEqual(str(context.exception),
105+
"Password must be between 8 and 20 characters long, contain at least one uppercase letter, one "
106+
"lowercase letter, one number and one special character.")

tests/test_database/test_queries/test_users.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from unittest import TestCase
22
from unittest.mock import patch
3+
from datetime import datetime, timedelta
34

4-
from ptmd.database.queries import login_user, create_organisations, create_users
5+
from ptmd.database.queries import login_user, create_organisations, create_users, get_token
6+
from ptmd.exceptions import TokenInvalidError, TokenExpiredError
57

68

79
INPUTS_ORGS = {'KIT': {"g_drive": "123", "long_name": "test12"}}
@@ -47,3 +49,19 @@ def test_create_users(self, mock_user, mock_organisation, mock_users_session, mo
4749
input_users = [{'username': 'test', 'password': 'test', 'organisation': organisations['KIT']}]
4850
user = create_users(users=input_users)
4951
self.assertEqual(user[0], 123)
52+
53+
@patch('ptmd.database.queries.users.Token')
54+
def test_get_token(self, mock_token):
55+
mock_token.query.filter().first.return_value = None
56+
with self.assertRaises(TokenInvalidError) as context:
57+
get_token('ABC')
58+
self.assertEqual(str(context.exception), 'Invalid token')
59+
60+
class MockToken:
61+
def __init__(self):
62+
self.expires_on = datetime.now() - timedelta(days=10)
63+
64+
mock_token.query.filter().first.return_value = MockToken()
65+
with self.assertRaises(TokenExpiredError) as context:
66+
get_token('ABC')
67+
self.assertEqual(str(context.exception), 'Token expired')

tests/test_exceptions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from unittest import TestCase
2+
3+
from ptmd.exceptions import APIError
4+
5+
6+
class TestExceptions(TestCase):
7+
8+
def test_api_error(self):
9+
with self.assertRaises(SyntaxError) as context:
10+
APIError()
11+
self.assertEqual(str(context.exception), 'Cannot instantiate abstract class APIError')

0 commit comments

Comments
 (0)