Feat(API): members CRUD functionalities
- Added tests for members flow (creating/deleting/reset) - Various fixes to tooling
This commit is contained in:
		
				
					committed by
					
						
						Minh Tran Nhat
					
				
			
			
				
	
			
			
			
						parent
						
							026be1328e
						
					
				
				
					commit
					3c78fe9133
				
			
							
								
								
									
										10
									
								
								backend/pdm.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								backend/pdm.lock
									
									
									
										generated
									
									
									
								
							@@ -554,9 +554,14 @@ dependencies = [
 | 
				
			|||||||
    "h11<1,>=0.9.0",
 | 
					    "h11<1,>=0.9.0",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "zxcvbn"
 | 
				
			||||||
 | 
					version = "4.4.28"
 | 
				
			||||||
 | 
					summary = ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[metadata]
 | 
					[metadata]
 | 
				
			||||||
lock_version = "4.1"
 | 
					lock_version = "4.1"
 | 
				
			||||||
content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338ef1b985"
 | 
					content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[metadata.files]
 | 
					[metadata.files]
 | 
				
			||||||
"aiofiles 22.1.0" = [
 | 
					"aiofiles 22.1.0" = [
 | 
				
			||||||
@@ -1027,3 +1032,6 @@ content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338
 | 
				
			|||||||
    {url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
 | 
					    {url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
 | 
				
			||||||
    {url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
 | 
					    {url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					"zxcvbn 4.4.28" = [
 | 
				
			||||||
 | 
					    {url = "https://files.pythonhosted.org/packages/54/67/c6712608c99e7720598e769b8fb09ebd202119785adad0bbce25d330243c/zxcvbn-4.4.28.tar.gz", hash = "sha256:151bd816817e645e9064c354b13544f85137ea3320ca3be1fb6873ea75ef7dc1"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,7 @@ dependencies = [
 | 
				
			|||||||
    "quart-schema>=0.14.3",
 | 
					    "quart-schema>=0.14.3",
 | 
				
			||||||
    "quart-db[postgresql]>=0.4.1",
 | 
					    "quart-db[postgresql]>=0.4.1",
 | 
				
			||||||
    "httpx>=0.23.1",
 | 
					    "httpx>=0.23.1",
 | 
				
			||||||
 | 
					    "zxcvbn>=4.4.28",
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
[project.optional-dependencies]
 | 
					[project.optional-dependencies]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -78,4 +79,4 @@ recreate-db-base = "quart --app src/backend/run.py recreate_db"
 | 
				
			|||||||
recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
 | 
					recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"}
 | 
				
			||||||
test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
 | 
					test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
all = {composite = ["lint", "format", "test"]}
 | 
					all = {composite = ["format", "lint", "test"]}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										217
									
								
								backend/src/backend/blueprints/members.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								backend/src/backend/blueprints/members.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,217 @@
 | 
				
			|||||||
 | 
					from dataclasses import dataclass
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
 | 
					from typing import cast
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import asyncpg  # type: ignore
 | 
				
			||||||
 | 
					import bcrypt
 | 
				
			||||||
 | 
					from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
 | 
				
			||||||
 | 
					from pydantic import EmailStr
 | 
				
			||||||
 | 
					from quart import Blueprint, ResponseReturnValue, current_app, g
 | 
				
			||||||
 | 
					from quart_auth import current_user, login_required
 | 
				
			||||||
 | 
					from quart_rate_limiter import rate_limit
 | 
				
			||||||
 | 
					from quart_schema import validate_request
 | 
				
			||||||
 | 
					from zxcvbn import zxcvbn  # type: ignore
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from backend.lib.api_error import APIError
 | 
				
			||||||
 | 
					from backend.lib.email import send_email
 | 
				
			||||||
 | 
					from backend.models.member import (
 | 
				
			||||||
 | 
					    insert_member,
 | 
				
			||||||
 | 
					    select_member_by_email,
 | 
				
			||||||
 | 
					    select_member_by_id,
 | 
				
			||||||
 | 
					    update_member_email_verified,
 | 
				
			||||||
 | 
					    update_member_password,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					blueprint = Blueprint("members", __name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					MINIMUM_STRENGTH = 3
 | 
				
			||||||
 | 
					EMAIL_VERIFICATION_SALT = "email verify"
 | 
				
			||||||
 | 
					ONE_MONTH = int(timedelta(days=30).total_seconds())
 | 
				
			||||||
 | 
					FORGOTTEN_PASSWORD_SALT = "forgotten password"  # nosec
 | 
				
			||||||
 | 
					ONE_DAY = int(timedelta(hours=24).total_seconds())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class MemberData:
 | 
				
			||||||
 | 
					    email: str
 | 
				
			||||||
 | 
					    password: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@blueprint.post("/members/")
 | 
				
			||||||
 | 
					@rate_limit(10, timedelta(seconds=10))
 | 
				
			||||||
 | 
					@validate_request(MemberData)
 | 
				
			||||||
 | 
					async def register(data: MemberData) -> ResponseReturnValue:
 | 
				
			||||||
 | 
					    """Create a new Member.
 | 
				
			||||||
 | 
					    This allows a Member to be created.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    strength = zxcvbn(data.password)
 | 
				
			||||||
 | 
					    if strength["score"] < MINIMUM_STRENGTH:
 | 
				
			||||||
 | 
					        raise APIError(400, "WEAK_PASSWORD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    hashed_password = bcrypt.hashpw(
 | 
				
			||||||
 | 
					        data.password.encode("utf-8"),
 | 
				
			||||||
 | 
					        bcrypt.gensalt(14),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        member = await insert_member(
 | 
				
			||||||
 | 
					            g.connection,
 | 
				
			||||||
 | 
					            data.email,
 | 
				
			||||||
 | 
					            hashed_password.decode(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except asyncpg.exceptions.UniqueViolationError:
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        serializer = URLSafeTimedSerializer(
 | 
				
			||||||
 | 
					            current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        token = serializer.dumps(member.id)
 | 
				
			||||||
 | 
					        await send_email(
 | 
				
			||||||
 | 
					            member.email,
 | 
				
			||||||
 | 
					            "Welcome",
 | 
				
			||||||
 | 
					            "welcome.html",
 | 
				
			||||||
 | 
					            {"token": token},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {}, 201
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class TokenData:
 | 
				
			||||||
 | 
					    token: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@blueprint.put("/members/email/")
 | 
				
			||||||
 | 
					@rate_limit(5, timedelta(minutes=1))
 | 
				
			||||||
 | 
					@validate_request(TokenData)
 | 
				
			||||||
 | 
					async def verify_email(data: TokenData) -> ResponseReturnValue:
 | 
				
			||||||
 | 
					    """Call to verify an email.
 | 
				
			||||||
 | 
					    This requires the user to supply a valid token.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    serializer = URLSafeTimedSerializer(
 | 
				
			||||||
 | 
					        current_app.secret_key, salt=EMAIL_VERIFICATION_SALT
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        member_id = serializer.loads(data.token, max_age=ONE_MONTH)
 | 
				
			||||||
 | 
					    except SignatureExpired:
 | 
				
			||||||
 | 
					        raise APIError(403, "TOKEN_EXPIRED")
 | 
				
			||||||
 | 
					    except BadSignature:
 | 
				
			||||||
 | 
					        raise APIError(400, "TOKEN_INVALID")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        await update_member_email_verified(g.connection, member_id)
 | 
				
			||||||
 | 
					    return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class PasswordData:
 | 
				
			||||||
 | 
					    current_password: str
 | 
				
			||||||
 | 
					    new_password: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@blueprint.put("/members/password/")
 | 
				
			||||||
 | 
					@rate_limit(5, timedelta(minutes=1))
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					@validate_request(PasswordData)
 | 
				
			||||||
 | 
					async def change_password(data: PasswordData) -> ResponseReturnValue:
 | 
				
			||||||
 | 
					    """Update the members password.
 | 
				
			||||||
 | 
					    This allows the user to update their password.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    strength = zxcvbn(data.new_password)
 | 
				
			||||||
 | 
					    if strength["score"] < MINIMUM_STRENGTH:
 | 
				
			||||||
 | 
					        raise APIError(400, "WEAK_PASSWORD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    member_id = int(cast(str, current_user.auth_id))
 | 
				
			||||||
 | 
					    member = await select_member_by_id(g.connection, member_id)
 | 
				
			||||||
 | 
					    assert member is not None  # nosec
 | 
				
			||||||
 | 
					    passwords_match = bcrypt.checkpw(
 | 
				
			||||||
 | 
					        data.current_password.encode("utf-8"),
 | 
				
			||||||
 | 
					        member.password_hash.encode("utf-8"),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    if not passwords_match:
 | 
				
			||||||
 | 
					        raise APIError(401, "INVALID_PASSWORD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    hashed_password = bcrypt.hashpw(
 | 
				
			||||||
 | 
					        data.new_password.encode("utf-8"),
 | 
				
			||||||
 | 
					        bcrypt.gensalt(14),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    await update_member_password(g.connection, member_id, hashed_password.decode())
 | 
				
			||||||
 | 
					    await send_email(
 | 
				
			||||||
 | 
					        member.email,
 | 
				
			||||||
 | 
					        "Password changed",
 | 
				
			||||||
 | 
					        "password_changed.html",
 | 
				
			||||||
 | 
					        {},
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class ForgottenPasswordData:
 | 
				
			||||||
 | 
					    email: EmailStr
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@blueprint.put("/members/forgotten-password/")
 | 
				
			||||||
 | 
					@rate_limit(5, timedelta(minutes=1))
 | 
				
			||||||
 | 
					@validate_request(ForgottenPasswordData)
 | 
				
			||||||
 | 
					async def forgotten_password(data: ForgottenPasswordData) -> ResponseReturnValue:
 | 
				
			||||||
 | 
					    """Call to trigger a forgotten password email.
 | 
				
			||||||
 | 
					    This requires a valid member email.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    member = await select_member_by_email(g.connection, data.email)
 | 
				
			||||||
 | 
					    if member is not None:
 | 
				
			||||||
 | 
					        serializer = URLSafeTimedSerializer(
 | 
				
			||||||
 | 
					            current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        token = serializer.dumps(member.id)
 | 
				
			||||||
 | 
					        await send_email(
 | 
				
			||||||
 | 
					            member.email,
 | 
				
			||||||
 | 
					            "Forgotten password",
 | 
				
			||||||
 | 
					            "forgotten_password.html",
 | 
				
			||||||
 | 
					            {"token": token},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class ResetPasswordData:
 | 
				
			||||||
 | 
					    password: str
 | 
				
			||||||
 | 
					    token: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@blueprint.put("/members/reset-password/")
 | 
				
			||||||
 | 
					@rate_limit(5, timedelta(minutes=1))
 | 
				
			||||||
 | 
					@validate_request(ResetPasswordData)
 | 
				
			||||||
 | 
					async def reset_password(data: ResetPasswordData) -> ResponseReturnValue:
 | 
				
			||||||
 | 
					    """Call to reset a password using a token.
 | 
				
			||||||
 | 
					    This requires the user to supply a valid token and a
 | 
				
			||||||
 | 
					    new password.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    serializer = URLSafeTimedSerializer(
 | 
				
			||||||
 | 
					        current_app.secret_key, salt=FORGOTTEN_PASSWORD_SALT
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        member_id = serializer.loads(data.token, max_age=ONE_DAY)
 | 
				
			||||||
 | 
					    except SignatureExpired:
 | 
				
			||||||
 | 
					        raise APIError(403, "TOKEN_EXPIRED")
 | 
				
			||||||
 | 
					    except BadSignature:
 | 
				
			||||||
 | 
					        raise APIError(400, "TOKEN_INVALID")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        strength = zxcvbn(data.password)
 | 
				
			||||||
 | 
					        if strength["score"] < MINIMUM_STRENGTH:
 | 
				
			||||||
 | 
					            raise APIError(400, "WEAK_PASSWORD")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        hashed_password = bcrypt.hashpw(
 | 
				
			||||||
 | 
					            data.password.encode("utf-8"),
 | 
				
			||||||
 | 
					            bcrypt.gensalt(14),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        await update_member_password(g.connection, member_id, hashed_password.decode())
 | 
				
			||||||
 | 
					        member = await select_member_by_id(
 | 
				
			||||||
 | 
					            g.connection, int(cast(str, current_user.auth_id))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert member is not None  # nosec
 | 
				
			||||||
 | 
					        await send_email(
 | 
				
			||||||
 | 
					            member.email,
 | 
				
			||||||
 | 
					            "Password changed",
 | 
				
			||||||
 | 
					            "password_changed.html",
 | 
				
			||||||
 | 
					            {},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    return {}
 | 
				
			||||||
@@ -20,6 +20,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Each blueprint is a logical collection of features in our web app
 | 
					# Each blueprint is a logical collection of features in our web app
 | 
				
			||||||
from backend.blueprints.control import blueprint as control_blueprint
 | 
					from backend.blueprints.control import blueprint as control_blueprint
 | 
				
			||||||
 | 
					from backend.blueprints.members import blueprint as members_blueprint
 | 
				
			||||||
from backend.blueprints.sessions import blueprint as sessions_blueprint
 | 
					from backend.blueprints.sessions import blueprint as sessions_blueprint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# For making sure error responses are in JSON format
 | 
					# For making sure error responses are in JSON format
 | 
				
			||||||
@@ -40,6 +41,7 @@ logging.basicConfig(level=logging.INFO)
 | 
				
			|||||||
# registers these groups of routes handlers
 | 
					# registers these groups of routes handlers
 | 
				
			||||||
app.register_blueprint(control_blueprint)
 | 
					app.register_blueprint(control_blueprint)
 | 
				
			||||||
app.register_blueprint(sessions_blueprint)
 | 
					app.register_blueprint(sessions_blueprint)
 | 
				
			||||||
 | 
					app.register_blueprint(members_blueprint)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# rate limiting
 | 
					# rate limiting
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
<!DOCTYPE html>
 | 
					<!DOCTYPE html>
 | 
				
			||||||
<html>
 | 
					<html>
 | 
				
			||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <title>Tozo - email</title>
 | 
					    <title>Todo - email</title>
 | 
				
			||||||
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 | 
					    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
@@ -41,7 +41,7 @@
 | 
				
			|||||||
              </td>
 | 
					              </td>
 | 
				
			||||||
            </tr>
 | 
					            </tr>
 | 
				
			||||||
            <tr>
 | 
					            <tr>
 | 
				
			||||||
              <td align="center" width="540">The Tozo team</td>
 | 
					              <td align="center" width="540">The Todo team</td>
 | 
				
			||||||
            </tr>
 | 
					            </tr>
 | 
				
			||||||
          </table>
 | 
					          </table>
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										9
									
								
								backend/src/backend/templates/forgotten_password.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								backend/src/backend/templates/forgotten_password.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					{% extends "email.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					  You can use this
 | 
				
			||||||
 | 
					  <a href="{{ config['BASE_URL'] }}/reset-password/{{ token }}/">
 | 
				
			||||||
 | 
					    link
 | 
				
			||||||
 | 
					  </a>
 | 
				
			||||||
 | 
					  to reset your password.
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										6
									
								
								backend/src/backend/templates/password_changed.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								backend/src/backend/templates/password_changed.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					{% extends "email.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					  Your Todo password has been successfully changed.
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										12
									
								
								backend/src/backend/templates/welcome.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								backend/src/backend/templates/welcome.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					{% extends "email.html" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block welcome %}
 | 
				
			||||||
 | 
					  Hello and welcome to tozo!
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					  Please confirm you signed up by following this
 | 
				
			||||||
 | 
					  <a href="{{ config['BASE_URL'] }}/confirm-email/{{ token }}/">
 | 
				
			||||||
 | 
					    link
 | 
				
			||||||
 | 
					  </a>.
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										66
									
								
								backend/tests/blueprints/test_members.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/tests/blueprints/test_members.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
				
			|||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					from freezegun import freeze_time
 | 
				
			||||||
 | 
					from itsdangerous import URLSafeTimedSerializer
 | 
				
			||||||
 | 
					from quart import Quart
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from backend.blueprints.members import EMAIL_VERIFICATION_SALT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def test_register(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
 | 
				
			||||||
 | 
					    test_client = app.test_client()
 | 
				
			||||||
 | 
					    data = {
 | 
				
			||||||
 | 
					        "email": "new@todo.minhtrannhat.com",
 | 
				
			||||||
 | 
					        "password": "testPassword2$",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    await test_client.post("/members/", json=data)
 | 
				
			||||||
 | 
					    response = await test_client.post("/sessions/", json=data)
 | 
				
			||||||
 | 
					    assert response.status_code == 200
 | 
				
			||||||
 | 
					    assert "Sending welcome.html to new@todo.minhtrannhat.com" in caplog.text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@pytest.mark.parametrize(
 | 
				
			||||||
 | 
					    "time, expected",
 | 
				
			||||||
 | 
					    [("2022-01-01", 403), (None, 200)],
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					async def test_verify_email(app: Quart, time: str | None, expected: int) -> None:
 | 
				
			||||||
 | 
					    with freeze_time(time):
 | 
				
			||||||
 | 
					        signer = URLSafeTimedSerializer(app.secret_key, salt=EMAIL_VERIFICATION_SALT)
 | 
				
			||||||
 | 
					        token = signer.dumps(1)
 | 
				
			||||||
 | 
					    test_client = app.test_client()
 | 
				
			||||||
 | 
					    response = await test_client.put("/members/email/", json={"token": token})
 | 
				
			||||||
 | 
					    assert response.status_code == expected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def test_verify_email_invalid_token(app: Quart) -> None:
 | 
				
			||||||
 | 
					    test_client = app.test_client()
 | 
				
			||||||
 | 
					    response = await test_client.put("/members/email/", json={"token": "invalid"})
 | 
				
			||||||
 | 
					    assert response.status_code == 400
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def test_change_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
 | 
				
			||||||
 | 
					    test_client = app.test_client()
 | 
				
			||||||
 | 
					    data = {
 | 
				
			||||||
 | 
					        "email": "new_password@todo.minhtrannhat.com",
 | 
				
			||||||
 | 
					        "password": "testPassword2$",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    response = await test_client.post("/members/", json=data)
 | 
				
			||||||
 | 
					    async with test_client.authenticated("2"):  # type: ignore
 | 
				
			||||||
 | 
					        response = await test_client.put(
 | 
				
			||||||
 | 
					            "/members/password/",
 | 
				
			||||||
 | 
					            json={
 | 
				
			||||||
 | 
					                "currentPassword": data["password"],
 | 
				
			||||||
 | 
					                "newPassword": "testPassword3$",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        assert response.status_code == 200
 | 
				
			||||||
 | 
					    assert "Sending password_changed.html to new@todo.minhtrannhat.com" in caplog.text
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def test_forgotten_password(app: Quart, caplog: pytest.LogCaptureFixture) -> None:
 | 
				
			||||||
 | 
					    test_client = app.test_client()
 | 
				
			||||||
 | 
					    data = {"email": "member@todo.minhtrannhat.com"}
 | 
				
			||||||
 | 
					    response = await test_client.put("/members/forgotten-password/", json=data)
 | 
				
			||||||
 | 
					    assert response.status_code == 200
 | 
				
			||||||
 | 
					    assert (
 | 
				
			||||||
 | 
					        "Sending forgotten_password.html to member@todo.minhtrannhat.com" in caplog.text
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
		Reference in New Issue
	
	Block a user