diff --git a/lib/db.py b/lib/db.py new file mode 100644 index 0000000..f3ec051 --- /dev/null +++ b/lib/db.py @@ -0,0 +1,10 @@ +from typing import List + +from sqlalchemy import text +from sqlalchemy.orm import Session + + +def init_tables(db_session: Session, tables: List[str]): + for table in tables: + db_session.execute(text("TRUNCATE " + table + " RESTART IDENTITY CASCADE;")) + db_session.commit() diff --git a/lib/unit_test.py b/lib/unit_test.py new file mode 100644 index 0000000..b00f9f4 --- /dev/null +++ b/lib/unit_test.py @@ -0,0 +1,6 @@ +class EverythingEquals: + def __eq__(self, other): + return True + + +everything_equals = EverythingEquals() diff --git a/poetry.lock b/poetry.lock index 38bf803..4effebc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -50,6 +50,46 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "bcrypt" +version = "4.2.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7"}, + {file = "bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458"}, + {file = "bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5"}, + {file = "bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8"}, + {file = "bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34"}, + {file = "bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184"}, + {file = "bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "cfgv" version = "3.4.0" @@ -375,6 +415,26 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +optional = false +python-versions = "*" +files = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] +totp = ["cryptography"] + [[package]] name = "platformdirs" version = "4.2.2" @@ -877,4 +937,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "5064c5c9ee740b8883faea43c3f0f4b222fc252d488bb5287ab1c6e8e5835aba" +content-hash = "2528feb00c37df4e7f9e92d951981a25773e95d04b2c8cfb21f637af7d128230" diff --git a/projects/account_password_management/alembic/env.py b/projects/account_password_management/alembic/env.py index 36112a3..6a5553b 100644 --- a/projects/account_password_management/alembic/env.py +++ b/projects/account_password_management/alembic/env.py @@ -1,9 +1,9 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool - from alembic import context +from sqlalchemy import create_engine +from src.models import account +from src.utils.db_connector import db # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -18,7 +18,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = None +target_metadata = db.get_base_metadata() # other values from the config, defined by the needs of env.py, # can be acquired: @@ -38,7 +38,7 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = db.get_db_url() context.configure( url=url, target_metadata=target_metadata, @@ -57,15 +57,13 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + connectable = create_engine(db.get_db_url()) with connectable.connect() as connection: context.configure( - connection=connection, target_metadata=target_metadata + connection=connection, + target_metadata=target_metadata, + compare_type=True, ) with context.begin_transaction(): diff --git a/projects/account_password_management/alembic/versions/1_account_create_table.py b/projects/account_password_management/alembic/versions/1_account_create_table.py new file mode 100644 index 0000000..e12161a --- /dev/null +++ b/projects/account_password_management/alembic/versions/1_account_create_table.py @@ -0,0 +1,35 @@ +"""account create table + +Revision ID: 1 +Revises: +Create Date: 2024-08-05 14:12:39.901690 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '1' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('account', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=32), nullable=False), + sa.Column('password', sa.String(length=64), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('account') + # ### end Alembic commands ### diff --git a/projects/account_password_management/src/api/account/controller.py b/projects/account_password_management/src/api/account/controller.py index cd79eaf..cacfe49 100644 --- a/projects/account_password_management/src/api/account/controller.py +++ b/projects/account_password_management/src/api/account/controller.py @@ -2,7 +2,9 @@ from sqlalchemy.orm import Session from src.api import account +from src.models.account import Account as AccountModel from src.schema.account import Account as AccountSchema +from src.utils.password import pwd_context from lib.exceptions import CustomHTTPException, UnprocessableEntityException @@ -26,11 +28,14 @@ class Account: def __init__(self, account: AccountSchema) -> None: self.__account: AccountSchema = account - async def create_account(self) -> None: + async def create_account(self, db_session: Session) -> None: try: await self.__verify_created_account_format() await self.__hash_password() - await account.query.create_account() + await account.query.create_account( + account=AccountModel(**self.__account.model_dump()), + db_session=db_session, + ) except CustomHTTPException as exception: raise exception @@ -83,4 +88,4 @@ async def __verify_password_component(self, password: str) -> None: ) async def __hash_password(self) -> None: - return + self.__account.password = pwd_context.hash(self.__account.password) diff --git a/projects/account_password_management/src/api/account/query.py b/projects/account_password_management/src/api/account/query.py index 9447241..cf07378 100644 --- a/projects/account_password_management/src/api/account/query.py +++ b/projects/account_password_management/src/api/account/query.py @@ -1,20 +1,29 @@ +from sqlalchemy.exc import IntegrityError, NoResultFound +from sqlalchemy.orm import Session +from src.models.account import Account + from lib.exceptions import ConflictException, NotFound USERNAME_NOT_EXISTS_REASON = "Username not exists" USERNAME_ALREADY_EXISTS_REASON = "Username already exists" -async def create_account(account) -> None: +async def create_account(account: Account, db_session: Session) -> None: try: - return + db_session.add(account) + db_session.flush() - except: + except IntegrityError: raise ConflictException(detail=USERNAME_ALREADY_EXISTS_REASON) -async def get_account(username: str) -> None: +async def get_account(username: str, db_session: Session) -> Account: try: - return - - except: + return ( + db_session.query(Account) + .filter(Account.username == username) + .with_for_update() + .one() + ) + except NoResultFound: raise NotFound(detail=USERNAME_NOT_EXISTS_REASON) diff --git a/projects/account_password_management/src/api/account/routers.py b/projects/account_password_management/src/api/account/routers.py index 971ca52..d63af33 100644 --- a/projects/account_password_management/src/api/account/routers.py +++ b/projects/account_password_management/src/api/account/routers.py @@ -1,15 +1,14 @@ -from fastapi import APIRouter, status +from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse -from src.api.account.controller import ( - PASSWORD_COMPONENT_NOT_CORRECT_REASON, - PASSWORD_TOO_LONG_REASON, - PASSWORD_TOO_SHORT_REASON, - USERNAME_TOO_LONG_REASON, - USERNAME_TOO_SHORT_REASON, - Account, -) +from sqlalchemy.orm import Session +from src.api.account.controller import (PASSWORD_COMPONENT_NOT_CORRECT_REASON, + PASSWORD_TOO_LONG_REASON, + PASSWORD_TOO_SHORT_REASON, + USERNAME_TOO_LONG_REASON, + USERNAME_TOO_SHORT_REASON, Account) from src.api.account.query import USERNAME_ALREADY_EXISTS_REASON from src.schema.account import Account as AccountSchema +from src.utils.db_connector import db from lib.api_doc_response import api_doc_response from lib.custom_response import failed_response, success_response @@ -64,12 +63,15 @@ ) async def create_account( account_data: AccountSchema, + db_session: Session = Depends(db.get_db_session), ) -> ResponseSuccess: """ - "username": a string representing the desired username for the account, with a minimum length of 3 characters and a maximum length of 32 characters. - "password": a string representing the desired password for the account, with a minimum length of 8 characters and a maximum length of 32 characters, containing at least 1 uppercase letter, 1 lowercase letter, and 1 number. """ account = Account(account_data) - await account.create_account() + await account.create_account(db_session) + + db_session.commit() return JSONResponse(status_code=status.HTTP_201_CREATED, content=success_response()) diff --git a/projects/account_password_management/src/api/authentication/controller.py b/projects/account_password_management/src/api/authentication/controller.py index d69dce1..48ec2e4 100644 --- a/projects/account_password_management/src/api/authentication/controller.py +++ b/projects/account_password_management/src/api/authentication/controller.py @@ -1,6 +1,5 @@ from datetime import timedelta - from src.api.account.query import get_account from src.schema.account import Account as AccountSchema diff --git a/projects/account_password_management/src/api/authentication/routers.py b/projects/account_password_management/src/api/authentication/routers.py index 8da970f..843453d 100644 --- a/projects/account_password_management/src/api/authentication/routers.py +++ b/projects/account_password_management/src/api/authentication/routers.py @@ -1,16 +1,12 @@ from fastapi import APIRouter, status from src.api.account.query import USERNAME_NOT_EXISTS_REASON from src.api.authentication.controller import ( - PASSWORD_NOT_CORRECT_REASON, - TOO_MANY_FAILED_VERIFICATION_ATTEMPTS_REASON, - Authentication, -) + PASSWORD_NOT_CORRECT_REASON, TOO_MANY_FAILED_VERIFICATION_ATTEMPTS_REASON, + Authentication) from src.schema.account import Account - from lib.api_doc_response import api_doc_response from lib.custom_response import failed_response, success_response - from lib.schema import ResponseSuccess router = APIRouter() diff --git a/projects/account_password_management/src/main.py b/projects/account_password_management/src/main.py index 5926756..fb7fdbc 100644 --- a/projects/account_password_management/src/main.py +++ b/projects/account_password_management/src/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from src.api import account, authentication + from lib.custom_http_exception import custom_http_exception_handler from lib.exceptions import CustomHTTPException, UnprocessableEntityException diff --git a/projects/account_password_management/src/models/__init__.py b/projects/account_password_management/src/models/__init__.py index e69de29..807818b 100644 --- a/projects/account_password_management/src/models/__init__.py +++ b/projects/account_password_management/src/models/__init__.py @@ -0,0 +1 @@ +from src.models import account, alembic_version diff --git a/projects/account_password_management/src/models/account.py b/projects/account_password_management/src/models/account.py new file mode 100644 index 0000000..6c4debb --- /dev/null +++ b/projects/account_password_management/src/models/account.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String +from src.utils.db_connector import db + + +class Account(db.get_base()): + __tablename__ = "account" + + id = Column(Integer, primary_key=True) + username = Column(String(32), nullable=False, unique=True) + password = Column(String(64), nullable=False) diff --git a/projects/account_password_management/src/models/alembic_version.py b/projects/account_password_management/src/models/alembic_version.py new file mode 100644 index 0000000..d40f988 --- /dev/null +++ b/projects/account_password_management/src/models/alembic_version.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, String +from src.utils.db_connector import db + + +# pylint: disable=too-few-public-methods +class AlembicVersion( + db.get_base() +): # for pytest conftest init_db_schema to drop alembic_version table + __tablename__ = "alembic_version" + + version_num = Column(String(32), primary_key=True, nullable=False) diff --git a/projects/account_password_management/src/utils/password.py b/projects/account_password_management/src/utils/password.py new file mode 100644 index 0000000..64f57fd --- /dev/null +++ b/projects/account_password_management/src/utils/password.py @@ -0,0 +1,4 @@ +from passlib.context import CryptContext + +# autogenerate salt https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html#passlib.hash.bcrypt +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") diff --git a/projects/account_password_management/tests/__init__.py b/projects/account_password_management/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/account_password_management/tests/conftest.py b/projects/account_password_management/tests/conftest.py new file mode 100644 index 0000000..dbb622d --- /dev/null +++ b/projects/account_password_management/tests/conftest.py @@ -0,0 +1,77 @@ +import alembic.config +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from src.config.config import settings +from src.main import app +from src.utils.db_connector import db + +from lib.db import init_tables + + +@pytest.fixture(scope="session", autouse=True) +def test_client(): + # When you need your event handlers (startup and shutdown) to run in your tests + # https://fastapi.tiangolo.com/advanced/testing-events/ + with TestClient(app) as test_client_: + yield test_client_ + + +@pytest.fixture(scope="session") +def db_engine(): + db_engine_ = db.get_engine() + + yield db_engine_ + + +@pytest.fixture(scope="session") +def init_db_schema(db_engine): + db.get_base_metadata().drop_all(bind=db_engine) + + __db_migration() + + yield + + db.get_base_metadata().drop_all(bind=db_engine) + + +def __db_migration(): + alembic_args = [ + "-c", + f"{settings.PROJECT_PATH}/alembic.ini", + "--raiseerr", + "upgrade", + "head", + ] + alembic.config.main(argv=alembic_args) + + +@pytest.fixture(scope="session") +def db_session_factory(init_db_schema): + try: + yield next(db.get_db_session()) + except StopIteration: + print("db_session_factory fail") + yield None + + +@pytest.fixture(scope="session") +def db_session(db_session_factory): + db_session_ = db_session_factory + + yield db_session_ + + db_session_.close() + + +@pytest.fixture(scope="function", autouse=True) +def init_db(db_session): + + yield + + # disables all foreign key checks, + db_session.execute(text("SET session_replication_role = 'replica'")) + init_tables(db_session=db_session, tables=db.get_base_metadata().tables.keys()) + # enables all foreign key checks, + db_session.execute(text("SET session_replication_role = 'origin'")) + db_session.commit() diff --git a/projects/account_password_management/tests/test_account.py b/projects/account_password_management/tests/test_account.py new file mode 100644 index 0000000..5921c7d --- /dev/null +++ b/projects/account_password_management/tests/test_account.py @@ -0,0 +1,170 @@ +import pytest +from fastapi import status +from fastapi.encoders import jsonable_encoder +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from src.api.account.query import get_account +from src.main import ACCOUNT_API_ROUTE + +from lib.custom_response import failed_response +from lib.unit_test import everything_equals + + +class TestCreateAccount: + CREATE_ACCOUNT_API_ROUTE = ACCOUNT_API_ROUTE + + @pytest.mark.asyncio + async def test_create_account(self, test_client: TestClient, db_session: Session): + test_data = { + "username": "123", + "password": "Test1234", + } + test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_result = { + "id": 1, + "username": test_data["username"], + "password": everything_equals, + } + + account = await get_account( + username=test_data["username"], db_session=db_session + ) + + assert jsonable_encoder(account) == expected_result + + @pytest.mark.asyncio + async def test_create_account_failed_because_username_too_short( + self, test_client: TestClient + ): + test_data = { + "username": "12", + "password": "Test1234", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response(reason="Username is too short") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_username_too_long( + self, test_client: TestClient + ): + test_data = { + "username": "123456789012345678901234567890123", + "password": "Test1234", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response(reason="Username is too long") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_username_exist( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "Test1234", + } + + test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response(reason="Username already exists") + + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_password_too_short( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "1234567", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response(reason="Password is too short") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_password_too_long( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "123456789012345678901234567890123", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response(reason="Password is too long") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_password_not_contain_uppercase_letter( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "test1234", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response( + reason="Password not contain at least 1 uppercase letter, 1 lowercase letter, and 1 number" + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_password_not_contain_lowercase_letter( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "TEST1234", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response( + reason="Password not contain at least 1 uppercase letter, 1 lowercase letter, and 1 number" + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response + + @pytest.mark.asyncio + async def test_create_account_failed_because_password_not_contain_number( + self, test_client: TestClient + ): + test_data = { + "username": "123", + "password": "TestTest", + } + + response = test_client.post(self.CREATE_ACCOUNT_API_ROUTE, json=test_data) + + expected_response = failed_response( + reason="Password not contain at least 1 uppercase letter, 1 lowercase letter, and 1 number" + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == expected_response diff --git a/pyproject.toml b/pyproject.toml index c301439..3f0dbb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ pydantic-settings = "^2.4.0" sqlalchemy = "^2.0.31" alembic = "^1.13.2" psycopg2 = "^2.9.9" +passlib = {extras = ["bcrypt"], version = "^1.7.4"} [tool.poetry.group.dev.dependencies] @@ -24,3 +25,7 @@ pre-commit = "^3.8.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] +"env.py" = ["F401"] \ No newline at end of file