From f129c6884eb22ccea8b0c29e02b1e7d937d5f4bb Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Sat, 24 Dec 2022 22:01:23 -0500 Subject: [PATCH 1/3] Feat(API + Docs): Handle user sessions - Improved documentation - Wrote tests for user sessions/authentications: including session flow(login, status, logout) --- README.md | 22 ++++++- backend/pyproject.toml | 2 + backend/src/backend/blueprints/sessions.py | 73 ++++++++++++++++++++++ backend/src/backend/run.py | 3 + backend/tests/blueprints/test_sessions.py | 24 +++++++ 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 backend/src/backend/blueprints/sessions.py create mode 100644 backend/tests/blueprints/test_sessions.py diff --git a/README.md b/README.md index 4f8e96b..b294e1d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ ### Development workflow -- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root. +- Run `eval (pdm venv activate in-project)` (if you are using Fish shell) or `eval $(pdm venv activate in-project)` (if you are using bash/zsh) at the `backend` folder root to get into the backend python virtual environment. +- Run tests, lints, formats with `pdm run {test, lint, format}`. ### Dependencies @@ -40,7 +41,26 @@ ### Backend Technical Write-up +#### Quart specific terminologies + +`blueprint`: a collection of route handlers/API functionalities. + +#### API route trailing slashes + +API paths should end with a slash i.e: `/sessions/` rather than `/session`. +This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected. + #### Difference between database schema and database model - A schema defines the structure of data within the database. - A model is a class that can be represented as rows in the database, i.e ID row, age row as class member. + +#### Managing user's sessions (Authentication) + +- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request. + The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request. +- Logout results in the cookie being deleted. + +#### Idempotent routes + +Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 022973a..2b4e136 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -77,3 +77,5 @@ start = {cmd = "quart --app src/backend/run.py run --port 5050", env_file = "dev recreate-db-base = "quart --app src/backend/run.py recreate_db" recreate-db = {composite = ["recreate-db-base"], env_file = "development.env"} test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"} + +all = {composite = ["lint", "format", "test"]} diff --git a/backend/src/backend/blueprints/sessions.py b/backend/src/backend/blueprints/sessions.py new file mode 100644 index 0000000..13277c0 --- /dev/null +++ b/backend/src/backend/blueprints/sessions.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from datetime import timedelta + +from bcrypt import checkpw +from pydantic import EmailStr +from quart import Blueprint, ResponseReturnValue, g +from quart_auth import AuthUser, current_user, login_required, login_user, logout_user +from quart_rate_limiter import rate_exempt, rate_limit +from quart_schema import validate_request, validate_response + +from backend.lib.api_error import APIError +from backend.models.member import select_member_by_email + +# Blueprint for user session (authentication) route handlers +blueprint = Blueprint("sessions", __name__) + + +@dataclass +class LoginData: + email: EmailStr + password: str + remember: bool = False + + +@dataclass +class Status: + member_id: int + + +@blueprint.post("/sessions/") +@rate_limit(5, timedelta(minutes=1)) +@validate_request(LoginData) +async def login(data: LoginData) -> ResponseReturnValue: + """ + Login to the app. + + By providing credentials and then saving the returned cookie. + """ + result = await select_member_by_email(g.connection, data.email) + + if result is None: + raise APIError(401, "INVALID_CREDENTIALS") + + passwords_match = checkpw( + data.password.encode("utf_8"), + result.password_hash.encode("utf_8"), + ) + + if passwords_match: + login_user(AuthUser(str(result.id)), data.remember) + return {}, 200 + + else: + raise APIError(401, "INVALID_CREDENTIALS") + + +@blueprint.delete("/sessions/") +@rate_exempt +async def logout() -> ResponseReturnValue: + """Logout from the app. + Deletes the session cookie. + """ + logout_user() + return {} + + +@blueprint.get("/sessions/") +@rate_limit(10, timedelta(minutes=1)) +@login_required +@validate_response(Status) +async def status() -> ResponseReturnValue: + assert current_user.auth_id is not None # nosec + return Status(member_id=int(current_user.auth_id)) diff --git a/backend/src/backend/run.py b/backend/src/backend/run.py index ed2de4c..1391154 100644 --- a/backend/src/backend/run.py +++ b/backend/src/backend/run.py @@ -20,6 +20,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError # Each blueprint is a logical collection of features in our web app from backend.blueprints.control import blueprint as control_blueprint +from backend.blueprints.sessions import blueprint as sessions_blueprint # For making sure error responses are in JSON format from backend.lib.api_error import APIError @@ -36,7 +37,9 @@ rate_limiter: RateLimiter = RateLimiter(app) schema = QuartSchema(app, convert_casing=True) logging.basicConfig(level=logging.INFO) +# registers these groups of routes handlers app.register_blueprint(control_blueprint) +app.register_blueprint(sessions_blueprint) # rate limiting diff --git a/backend/tests/blueprints/test_sessions.py b/backend/tests/blueprints/test_sessions.py new file mode 100644 index 0000000..0a552c9 --- /dev/null +++ b/backend/tests/blueprints/test_sessions.py @@ -0,0 +1,24 @@ +from quart import Quart + + +async def test_session_flow(app: Quart) -> None: + test_client = app.test_client() + await test_client.post( + "/sessions/", + json={"email": "member@todo.minhtrannhat.com", "password": "password"}, + ) + response = await test_client.get("/sessions/") + assert (await response.get_json())["memberId"] == 1 + await test_client.delete("/sessions/") + response = await test_client.get("/sessions/") + assert response.status_code == 401 + + +async def test_login_invalid_password(app: Quart) -> None: + test_client = app.test_client() + await test_client.post( + "/sessions/", + json={"email": "member@todo.minhtrannhat.com", "password": "incorrect"}, + ) + response = await test_client.get("/sessions/") + assert response.status_code == 401 From 21fd769fa995448fe827510e5129983a5da60008 Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Mon, 26 Dec 2022 14:56:54 -0500 Subject: [PATCH 2/3] Feat(API): members CRUD functionalities - Added tests for members flow (creating/deleting/reset) - Various fixes to tooling --- backend/pdm.lock | 10 +- backend/pyproject.toml | 3 +- backend/src/backend/blueprints/members.py | 217 ++++++++++++++++++ backend/src/backend/run.py | 2 + backend/src/backend/templates/email.html | 4 +- .../backend/templates/forgotten_password.html | 9 + .../backend/templates/password_changed.html | 6 + backend/src/backend/templates/welcome.html | 12 + backend/tests/blueprints/test_members.py | 66 ++++++ 9 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 backend/src/backend/blueprints/members.py create mode 100644 backend/src/backend/templates/forgotten_password.html create mode 100644 backend/src/backend/templates/password_changed.html create mode 100644 backend/src/backend/templates/welcome.html create mode 100644 backend/tests/blueprints/test_members.py diff --git a/backend/pdm.lock b/backend/pdm.lock index 951bc1f..3a6768f 100644 --- a/backend/pdm.lock +++ b/backend/pdm.lock @@ -554,9 +554,14 @@ dependencies = [ "h11<1,>=0.9.0", ] +[[package]] +name = "zxcvbn" +version = "4.4.28" +summary = "" + [metadata] lock_version = "4.1" -content_hash = "sha256:13ab53060a70e6594c56adc5109a6476e7c085b65be0550d113c28338ef1b985" +content_hash = "sha256:6d55ad044722dfe8cdb3f4b69623fec92653fe73f9ee7ae12717c06912aa3f1e" [metadata.files] "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/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"}, +] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2b4e136..06d684a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "quart-schema>=0.14.3", "quart-db[postgresql]>=0.4.1", "httpx>=0.23.1", + "zxcvbn>=4.4.28", ] [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"} test = {composite = ["recreate-db-base", "pytest tests/"], env_file = "testing.env"} -all = {composite = ["lint", "format", "test"]} +all = {composite = ["format", "lint", "test"]} diff --git a/backend/src/backend/blueprints/members.py b/backend/src/backend/blueprints/members.py new file mode 100644 index 0000000..5cdb594 --- /dev/null +++ b/backend/src/backend/blueprints/members.py @@ -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 {} diff --git a/backend/src/backend/run.py b/backend/src/backend/run.py index 1391154..55b56ba 100644 --- a/backend/src/backend/run.py +++ b/backend/src/backend/run.py @@ -20,6 +20,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError # Each blueprint is a logical collection of features in our web app 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 # For making sure error responses are in JSON format @@ -40,6 +41,7 @@ logging.basicConfig(level=logging.INFO) # registers these groups of routes handlers app.register_blueprint(control_blueprint) app.register_blueprint(sessions_blueprint) +app.register_blueprint(members_blueprint) # rate limiting diff --git a/backend/src/backend/templates/email.html b/backend/src/backend/templates/email.html index 9aef3dd..4a53a26 100644 --- a/backend/src/backend/templates/email.html +++ b/backend/src/backend/templates/email.html @@ -1,7 +1,7 @@ - Tozo - email + Todo - email @@ -41,7 +41,7 @@ - The Tozo team + The Todo team diff --git a/backend/src/backend/templates/forgotten_password.html b/backend/src/backend/templates/forgotten_password.html new file mode 100644 index 0000000..853efe4 --- /dev/null +++ b/backend/src/backend/templates/forgotten_password.html @@ -0,0 +1,9 @@ +{% extends "email.html" %} + +{% block content %} + You can use this + + link + + to reset your password. +{% endblock %} diff --git a/backend/src/backend/templates/password_changed.html b/backend/src/backend/templates/password_changed.html new file mode 100644 index 0000000..6127819 --- /dev/null +++ b/backend/src/backend/templates/password_changed.html @@ -0,0 +1,6 @@ + +{% extends "email.html" %} + +{% block content %} + Your Todo password has been successfully changed. +{% endblock %} diff --git a/backend/src/backend/templates/welcome.html b/backend/src/backend/templates/welcome.html new file mode 100644 index 0000000..c95adff --- /dev/null +++ b/backend/src/backend/templates/welcome.html @@ -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 + + link + . +{% endblock %} diff --git a/backend/tests/blueprints/test_members.py b/backend/tests/blueprints/test_members.py new file mode 100644 index 0000000..2f49025 --- /dev/null +++ b/backend/tests/blueprints/test_members.py @@ -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 + ) From d92982479c27af674303f6bff3116b5af50cd96a Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Tue, 27 Dec 2022 14:14:25 -0500 Subject: [PATCH 3/3] Feat(API): CRUD functionalities with todo items - wrote tests for todo items workflow --- backend/src/backend/blueprints/todos.py | 122 ++++++++++++++++++++++++ backend/src/backend/run.py | 2 + backend/tests/blueprints/test_todos.py | 54 +++++++++++ 3 files changed, 178 insertions(+) create mode 100644 backend/src/backend/blueprints/todos.py create mode 100644 backend/tests/blueprints/test_todos.py diff --git a/backend/src/backend/blueprints/todos.py b/backend/src/backend/blueprints/todos.py new file mode 100644 index 0000000..dcd5f6c --- /dev/null +++ b/backend/src/backend/blueprints/todos.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import cast + +from quart import Blueprint, ResponseReturnValue, g +from quart_auth import current_user, login_required +from quart_rate_limiter import rate_limit +from quart_schema import validate_querystring, validate_request, validate_response + +from backend.lib.api_error import APIError +from backend.models.todo import ( + Todo, + delete_todo, + insert_todo, + select_todo, + select_todos, + update_todo, +) + +blueprint = Blueprint("todos", __name__) + + +@dataclass +class TodoData: + complete: bool + due: datetime | None + task: str + + +@blueprint.post("/todos/") +@rate_limit(10, timedelta(seconds=10)) +@login_required +@validate_request(TodoData) +@validate_response(Todo, 201) +async def post_todo(data: TodoData) -> tuple[Todo, int]: + """Create a new Todo. + This allows todos to be created and stored. + """ + todo = await insert_todo( + g.connection, + int(cast(str, current_user.auth_id)), + data.task, + data.complete, + data.due, + ) + return todo, 201 + + +@blueprint.get("/todos//") +@rate_limit(10, timedelta(seconds=10)) +@login_required +@validate_response(Todo) +async def get_todo(id: int) -> Todo: + """Get a todo. + Fetch a Todo by its ID. + """ + todo = await select_todo(g.connection, id, int(cast(str, current_user.auth_id))) + if todo is None: + raise APIError(404, "NOT_FOUND") + else: + return todo + + +@dataclass +class Todos: + todos: list[Todo] + + +@dataclass +class TodoFilter: + complete: bool | None = None + + +@blueprint.get("/todos/") +@rate_limit(10, timedelta(seconds=10)) +@login_required +@validate_response(Todos) +@validate_querystring(TodoFilter) +async def get_todos(query_args: TodoFilter) -> Todos: + """Get the todos. + Fetch all the Todos optionally based on the complete status. + """ + todos = await select_todos( + g.connection, + int(cast(str, current_user.auth_id)), + query_args.complete, + ) + return Todos(todos=todos) + + +@blueprint.put("/todos//") +@rate_limit(10, timedelta(seconds=10)) +@login_required +@validate_request(TodoData) +@validate_response(Todo) +async def put_todo(id: int, data: TodoData) -> Todo: + """Update the identified todo + This allows the todo to be replaced with the request data. + """ + todo = await update_todo( + g.connection, + id, + int(cast(str, current_user.auth_id)), + data.task, + data.complete, + data.due, + ) + if todo is None: + raise APIError(404, "NOT_FOUND") + else: + return todo + + +@blueprint.delete("/todos//") +@rate_limit(10, timedelta(seconds=10)) +@login_required +async def todo_delete(id: int) -> ResponseReturnValue: + """Delete the identified todo + This will delete the todo. + """ + await delete_todo(g.connection, id, int(cast(str, current_user.auth_id))) + return "", 202 diff --git a/backend/src/backend/run.py b/backend/src/backend/run.py index 55b56ba..efe2ea2 100644 --- a/backend/src/backend/run.py +++ b/backend/src/backend/run.py @@ -22,6 +22,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError 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.todos import blueprint as todos_blueprint # For making sure error responses are in JSON format from backend.lib.api_error import APIError @@ -42,6 +43,7 @@ logging.basicConfig(level=logging.INFO) app.register_blueprint(control_blueprint) app.register_blueprint(sessions_blueprint) app.register_blueprint(members_blueprint) +app.register_blueprint(todos_blueprint) # rate limiting diff --git a/backend/tests/blueprints/test_todos.py b/backend/tests/blueprints/test_todos.py new file mode 100644 index 0000000..7f47c0f --- /dev/null +++ b/backend/tests/blueprints/test_todos.py @@ -0,0 +1,54 @@ +from quart import Quart + + +async def test_post_todo(app: Quart) -> None: + test_client = app.test_client() + async with test_client.authenticated("1"): # type: ignore + response = await test_client.post( + "/todos/", + json={"complete": False, "due": None, "task": "Test task"}, + ) + assert response.status_code == 201 + assert (await response.get_json())["id"] > 0 + + +async def test_get_todo(app: Quart) -> None: + test_client = app.test_client() + async with test_client.authenticated("1"): # type: ignore + response = await test_client.get("/todos/1/") + assert response.status_code == 200 + assert (await response.get_json())["task"] == "Test Task" + + +async def test_put_todo(app: Quart) -> None: + test_client = app.test_client() + async with test_client.authenticated("1"): # type: ignore + response = await test_client.post( + "/todos/", + json={"complete": False, "due": None, "task": "Test task"}, + ) + todo_id = (await response.get_json())["id"] + + response = await test_client.put( + f"/todos/{todo_id}/", + json={"complete": False, "due": None, "task": "Updated"}, + ) + assert (await response.get_json())["task"] == "Updated" + + response = await test_client.get(f"/todos/{todo_id}/") + assert (await response.get_json())["task"] == "Updated" + + +async def test_delete_todo(app: Quart) -> None: + test_client = app.test_client() + async with test_client.authenticated("1"): # type: ignore + response = await test_client.post( + "/todos/", + json={"complete": False, "due": None, "task": "Test task"}, + ) + todo_id = (await response.get_json())["id"] + + await test_client.delete(f"/todos/{todo_id}/") + + response = await test_client.get(f"/todos/{todo_id}/") + assert response.status_code == 404