From 865f345d77c2277fd8987d30e6271f68906b7871 Mon Sep 17 00:00:00 2001 From: Alex Goryev Date: Sat, 7 Oct 2023 16:10:22 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20#88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены модели базы данных - Акканут, Профиль, Хэш пароля и базовый функционал по созданию сущностей. --- .pylintrc | 1 + alembic.ini | 2 + ...ccount_profile_and_password_hash_tables.py | 68 ++++++++++++++ poetry.lock | 52 ++++++++++- pyproject.toml | 3 + src/account/models.py | 89 +++++++++++++++++++ src/account/schemas.py | 6 ++ src/auth/controllers.py | 28 ++++++ src/auth/routes.py | 10 ++- src/auth/schemas.py | 8 +- src/profile/models.py | 40 +++++++++ src/profile/schemas.py | 3 + src/shared/database.py | 2 + src/shared/exceptions.py | 13 +++ src/shared/schemas.py | 27 ++++++ tests/test_auth/__init__.py | 0 tests/test_auth/conftest.py | 14 +++ tests/test_auth/factories.py | 20 +++++ tests/test_auth/test_create_account.py | 58 ++++++++++++ whitelist.txt | 15 ++++ 20 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/2023_09_13_1747-e5b03d5c76f9_add_account_profile_and_password_hash_tables.py create mode 100644 src/account/models.py create mode 100644 src/auth/controllers.py create mode 100644 src/profile/models.py create mode 100644 tests/test_auth/__init__.py create mode 100644 tests/test_auth/conftest.py create mode 100644 tests/test_auth/factories.py create mode 100644 tests/test_auth/test_create_account.py diff --git a/.pylintrc b/.pylintrc index 5cebc134..5d48c887 100644 --- a/.pylintrc +++ b/.pylintrc @@ -88,6 +88,7 @@ disable = no-self-argument, nonexistent-operator, nonlocal-without-binding, + not-callable, not-in-loop, notimplemented-raised, pointless-statement, diff --git a/alembic.ini b/alembic.ini index ba934059..9e394749 100644 --- a/alembic.ini +++ b/alembic.ini @@ -6,6 +6,8 @@ script_location = ./migrations # absolute paths to the packages with models definitions models_packages = + src.account.models, + src.profile.models, # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s diff --git a/migrations/versions/2023_09_13_1747-e5b03d5c76f9_add_account_profile_and_password_hash_tables.py b/migrations/versions/2023_09_13_1747-e5b03d5c76f9_add_account_profile_and_password_hash_tables.py new file mode 100644 index 00000000..b3dce409 --- /dev/null +++ b/migrations/versions/2023_09_13_1747-e5b03d5c76f9_add_account_profile_and_password_hash_tables.py @@ -0,0 +1,68 @@ +"""add account, profile and password_hash tables + +Revision ID: e5b03d5c76f9 +Revises: 8f2f6221d1de +Create Date: 2023-09-13 17:47:54.104460+00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e5b03d5c76f9" +down_revision = "8f2f6221d1de" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "account", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("login", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("login"), + ) + op.create_table( + "password_hash", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("value", sa.LargeBinary(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "profile", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("avatar", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("profile") + op.drop_table("password_hash") + op.drop_table("account") + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index acaa7110..ee92a4b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -127,6 +127,41 @@ dev = ["Cython (>=0.29.24,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "flake8 (>=5.0.4 docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["flake8 (>=5.0.4,<5.1.0)", "uvloop (>=0.15.3)"] +[[package]] +name = "bcrypt" +version = "4.0.1" +description = "Modern password hashing for your software and your servers" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, + {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, + {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, + {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, + {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, + {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, + {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, + {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, + {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, + {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "black" version = "23.3.0" @@ -368,6 +403,21 @@ files = [ dnspython = ">=1.15.0" idna = ">=2.0.0" +[[package]] +name = "faker" +version = "19.2.0" +description = "Faker is a Python package that generates fake data for you." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Faker-19.2.0-py3-none-any.whl", hash = "sha256:c6c1218482faf79ae1d791bb7124067d12339e0b8f400de855e2c281bcf78c77"}, + {file = "Faker-19.2.0.tar.gz", hash = "sha256:78840b94843f3aa32a34a220b2b5e8b309e3ffff3a231b0c54e841bb68e0757d"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" + [[package]] name = "fastapi" version = "0.95.0" @@ -2260,4 +2310,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "463abf599ff2f419956b84060cf788af40ed8575fc4a6176488eff642354183c" +content-hash = "f5ac5b0b10b8ad2319b8f0c5c903178ae2e02607199d6bd9ebdfb06bc63a1762" diff --git a/pyproject.toml b/pyproject.toml index a695c4b9..ff83e826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ optional = true alembic = {extras = ["tz"], version = "1.10.4"} asyncpg = "0.27.0" # asynchronous postgresql driver used by sqlalchemy +bcrypt = "4.0.1" # modern password hashing library fastapi = {extras = ["all"], version = "0.95.0" } # we need "all" at least for uvicorn minio = "7.1.14" overrides = "7.3.1" @@ -57,6 +58,7 @@ flake8-mutable = "1.2.0" # Extension for mutable default arguments. flake8-rst-docstrings = "0.3.0" # Validate Python docstrings as reStructuredText (сRST) flake8-spellcheck = "0.28.0" # Spellcheck variables, classnames, comments, docstrings etc. mypy = "1.2.0" +psycopg2-binary = "2.9.7" pyenchant = "3.2.2" # needed for pylint's spellchecker pylint = "2.17.2" pylint-per-file-ignores = "1.2.0" # A pylint plugin to ignore error codes per file. @@ -72,6 +74,7 @@ optional = true [tool.poetry.group.test.dependencies] +faker = "19.2.0" # Tool to create mock data (Replace it by factory-boy in further updates, because factory-boy has faker as depenadancy) pytest = "7.3.0" pytest-cov = "4.0.0" pytest-env = "0.8.1" # needed to set up default $WLSS_ENV for tests execution diff --git a/src/account/models.py b/src/account/models.py new file mode 100644 index 00000000..3dae40bb --- /dev/null +++ b/src/account/models.py @@ -0,0 +1,89 @@ +"""Database models for account.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +import bcrypt +from sqlalchemy import DateTime, ForeignKey, func, Integer, LargeBinary, select, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database import Base +from src.shared.exceptions import DuplicateEntityException + + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from src.auth.fields import Password + from src.auth.schemas import NewAccount + + +class Account(Base): # pylint: disable=too-few-public-methods + """Account database model.""" + + __tablename__ = "account" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003 + + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + email: Mapped[str] = mapped_column(String, nullable=False, unique=True) + login: Mapped[str] = mapped_column(String, nullable=False, unique=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + + @staticmethod + async def create(session: AsyncSession, account_data: NewAccount) -> Account: + """Create new account object.""" + query = select(Account).where(Account.email == account_data.email) + account_with_same_email = (await session.execute(query)).first() + if account_with_same_email is not None: + raise DuplicateEntityException( + action="create Account", + details="Provided email address already exists.", + ) + + query = select(Account).where(Account.login == account_data.login) + account_with_same_login = (await session.execute(query)).first() + if account_with_same_login is not None: + raise DuplicateEntityException( + action="create Account", + details="Provided login already exists.", + ) + + account = Account(**account_data.dict(exclude={"password"})) + session.add(account) + await session.flush() + return account + + +class PasswordHash(Base): + """Password hash database model.""" + + __tablename__ = "password_hash" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003 + + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + # TODO: add uniqueness constraint since one account cannot have more than one password hash + # and check other kind of constraints for other models + account_id: Mapped[int] = mapped_column(ForeignKey("account.id")) + + @staticmethod # TODO: we cannot use this create method due to Password is a field and cannot be used outside of the schema + async def create(session: AsyncSession, password: Password, account_id: int) -> None: + """Create a password hash object.""" + hash_value = PasswordHash._generate_hash(password) + password_hash = PasswordHash(value=hash_value, account_id=account_id) + session.add(password_hash) + await session.flush() + return password_hash + + @staticmethod + def _generate_hash(password: Password) -> bytes: + """Generate hash for password.""" + salt = bcrypt.gensalt() + byte_password = password.get_secret_value().encode("utf-8") + return bcrypt.hashpw(byte_password, salt) diff --git a/src/account/schemas.py b/src/account/schemas.py index b26ef531..03472091 100644 --- a/src/account/schemas.py +++ b/src/account/schemas.py @@ -19,6 +19,9 @@ class Account(Schema): email: Email login: Login + class Config: # noqa: D106 + orm_mode = True + class AccountId(Schema): """Account Id.""" @@ -36,3 +39,6 @@ class AccountEmail(Schema): """Account email.""" email: Email + # TODO: do we really need to add it in this task? + # if not, check other places where update_at was added and remove them as well + updated_at: datetime diff --git a/src/auth/controllers.py b/src/auth/controllers.py new file mode 100644 index 00000000..0d560949 --- /dev/null +++ b/src/auth/controllers.py @@ -0,0 +1,28 @@ +"""Controllers functions for account entity.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from src.account import schemas as account_schemas +from src.account.models import Account, PasswordHash +from src.auth import schemas +from src.profile import schemas as profile_schemas +from src.profile.models import Profile + + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from src.auth.schemas import NewAccountWithProfile + + +async def create_account(request_data: NewAccountWithProfile, session: AsyncSession) -> schemas.AccountWithProfile: + """Create a new account with profile.""" + account = await Account.create(session, request_data.account) + profile = await Profile.create(session, request_data.profile, account_id=account.id) + await PasswordHash.create(session, request_data.account.password, account_id=account.id) + return schemas.AccountWithProfile( + account=account_schemas.Account.from_orm(account), + profile=profile_schemas.Profile.from_orm(profile), + ) diff --git a/src/auth/routes.py b/src/auth/routes.py index b2c2556e..9cd19791 100644 --- a/src/auth/routes.py +++ b/src/auth/routes.py @@ -8,10 +8,12 @@ from fastapi import APIRouter, Body, Depends, Path, status from fastapi.security import HTTPAuthorizationCredentials from pydantic import PositiveInt +from sqlalchemy.ext.asyncio import AsyncSession -from src.auth import schemas, swagger +from src.auth import controllers, schemas, swagger from src.auth.security import get_token from src.shared import swagger as shared_swagger +from src.shared.database import get_session router = APIRouter(tags=["auth"]) @@ -48,7 +50,7 @@ summary="Sign Up - create a new account.", ) async def create_account( - new_account: Annotated[ # noqa: ARG001 + new_account: Annotated[ schemas.NewAccountWithProfile, Body( example={ @@ -64,8 +66,10 @@ async def create_account( }, ), ], -) -> None: + session: AsyncSession = Depends(get_session), +) -> schemas.AccountWithProfile: """Create a new account with profile.""" + return await controllers.create_account(new_account, session) @router.post( diff --git a/src/auth/schemas.py b/src/auth/schemas.py index 99032663..69d0d5fa 100644 --- a/src/auth/schemas.py +++ b/src/auth/schemas.py @@ -22,7 +22,7 @@ class NotAuthenticatedResponse(HTTPError): # noqa: N818 """Schema for 401 UNAUTHORIZED response.""" -class _NewAccount(Schema): +class NewAccount(Schema): """Account data which is going to be created during sign up process.""" email: Email @@ -30,7 +30,7 @@ class _NewAccount(Schema): password: Password -class _NewProfile(Schema): +class NewProfile(Schema): """Profile data for an account which is going to be created during sign up process.""" name: Name @@ -40,8 +40,8 @@ class _NewProfile(Schema): class NewAccountWithProfile(Schema): """Account and corresponding profile data which are going to be created during sign up process.""" - account: _NewAccount - profile: _NewProfile + account: NewAccount + profile: NewProfile class AccountWithProfile(Schema): diff --git a/src/profile/models.py b/src/profile/models.py new file mode 100644 index 00000000..f7abe6e4 --- /dev/null +++ b/src/profile/models.py @@ -0,0 +1,40 @@ +"""Database models for profile.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, func, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database import Base + + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from src.auth.schemas import NewProfile + + +class Profile(Base): # pylint: disable=too-few-public-methods + """Profile database model.""" + + __tablename__ = "profile" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) # noqa: A003 + avatar: Mapped[str | None] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + description: Mapped[String | None] = mapped_column(String) + name: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + + account_id: Mapped[int] = mapped_column(ForeignKey("account.id")) + + @staticmethod + async def create(session: AsyncSession, profile_data: NewProfile, account_id: int) -> Profile: + """Create new profile object.""" + profile = Profile(**profile_data.dict(), account_id=account_id) + session.add(profile) + await session.flush() + return profile diff --git a/src/profile/schemas.py b/src/profile/schemas.py index 0783c39d..e7d5049b 100644 --- a/src/profile/schemas.py +++ b/src/profile/schemas.py @@ -17,6 +17,9 @@ class Profile(Schema): description: Description | None name: Name + class Config: # noqa: D106 + orm_mode = True + class ProfileUpdate(Schema): """Data for Profile update.""" diff --git a/src/shared/database.py b/src/shared/database.py index f9e32f5d..50063c02 100644 --- a/src/shared/database.py +++ b/src/shared/database.py @@ -54,5 +54,7 @@ async def get_session() -> AsyncIterator[AsyncSession]: # pragma: no cover except Exception: await session.rollback() raise + else: + await session.commit() finally: await session.close() diff --git a/src/shared/exceptions.py b/src/shared/exceptions.py index 5b3e6b17..0b61ab4d 100644 --- a/src/shared/exceptions.py +++ b/src/shared/exceptions.py @@ -47,3 +47,16 @@ def __init__(self: Self, action: str, *args: Any, **kwargs: Any) -> None: # pra """Initialize object.""" self.action = action super().__init__(*args, **kwargs) + + +class DuplicateEntityException(HTTPException): + """Exception for 400 Bad Request error if the entity is already exists.""" + + action: str + + description = "Bad request." + + def __init__(self: Self, action: str, *args: Any, **kwargs: Any) -> None: # pragma: no cover + """Initialize object.""" + self.action = action + super().__init__(*args, **kwargs) diff --git a/src/shared/schemas.py b/src/shared/schemas.py index 1a177cd5..fd97d561 100644 --- a/src/shared/schemas.py +++ b/src/shared/schemas.py @@ -2,14 +2,41 @@ from __future__ import annotations +import typing from datetime import datetime +from typing import Any, TypeVar from pydantic import BaseModel +from pydantic.utils import GetterDict + + +Model = TypeVar("Model", bound="BaseModel") class Schema(BaseModel): """Customized 'BaseModel' class from pydantic.""" + @classmethod + def from_orm(cls: type[Model], obj: object, **kwargs: object) -> Model: # pragma: no cover + """Get model from orm with ability to provide additional keyword arguments. + + If model and kwargs have the same attributes, then kwargs has precedence. + + Maybe it's not the best implementation, but it meets our requirements for now. + We can update our Pydantic and remove this method when following PR is merged: + https://github.com/samuelcolvin/pydantic/pull/3375 + + :param obj: an ORM object + :param kwargs: additional values to be provided for model + + :return: Pydantic's Model from ORM + """ + if not kwargs: + return super().from_orm(obj) + orm_object_as_dict = typing.cast(dict[str, Any], GetterDict(obj)) + orm_fields_with_overridden_from_kwargs = {**orm_object_as_dict, **kwargs} + return cls(**orm_fields_with_overridden_from_kwargs) + class Config: """Pydantic's special class to configure pydantic models.""" diff --git a/tests/test_auth/__init__.py b/tests/test_auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_auth/conftest.py b/tests/test_auth/conftest.py new file mode 100644 index 00000000..88382253 --- /dev/null +++ b/tests/test_auth/conftest.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pytest + +from src.account.models import Account + + +@pytest.fixture +async def db_with_one_account(db_empty): + session = db_empty + account = Account(email="john@doe.com", login="JohnDoe") + session.add(account) + await session.commit() + return session diff --git a/tests/test_auth/factories.py b/tests/test_auth/factories.py new file mode 100644 index 00000000..b8c173fb --- /dev/null +++ b/tests/test_auth/factories.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from faker import Faker + + +fake = Faker() + + +def create_account_request_body(): + return { + "account": { + "email": fake.email(), + "login": fake.profile()["username"], + "password": fake.password(), + }, + "profile": { + "name": fake.name(), + "description": fake.paragraph(), + }, + } diff --git a/tests/test_auth/test_create_account.py b/tests/test_auth/test_create_account.py new file mode 100644 index 00000000..005f1a93 --- /dev/null +++ b/tests/test_auth/test_create_account.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pytest +from sqlalchemy import select + +from src.account.models import Account, PasswordHash +from src.profile.models import Profile +from src.shared.exceptions import DuplicateEntityException +from tests.test_auth.factories import create_account_request_body + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_empty"}) +async def test_create_account_returns_correct_response(f): + result = await f.client.post("/accounts", json=create_account_request_body()) + + assert result.status_code == 201 + # TODO: add response body + # TODO: research how to avoid handwriting values for request/response + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_empty"}) +async def test_create_account_creates_objects_in_db_successful(f): + await f.client.post("/accounts", json=create_account_request_body()) + + accounts = (await f.db.execute(select(Account))).scalars().all() + profiles = (await f.db.execute(select(Profile))).scalars().all() + password_hashes = (await f.db.execute(select(PasswordHash))).scalars().all() + assert len(accounts) == 1 + assert len(profiles) == 1 + assert len(password_hashes) == 1 + assert profiles[0].account_id == accounts[0].id + assert password_hashes[0].account_id == accounts[0].id + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_with_one_account"}) +async def test_create_account_with_already_existed_email_returns_400_error(f): + body = create_account_request_body() + body["account"]["email"] = "john@doe.com" + + with pytest.raises(DuplicateEntityException) as err: + await f.client.post("/accounts", json=body) + + assert err.value.details == "Provided email address already exists." + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_with_one_account"}) +async def test_create_account_with_already_existed_login_returns_400_error(f): + body = create_account_request_body() + body["account"]["login"] = "JohnDoe" + + with pytest.raises(DuplicateEntityException) as err: + await f.client.post("/accounts", json=body) + + assert err.value.details == "Provided login already exists." diff --git a/whitelist.txt b/whitelist.txt index a34ce0b5..3fa9a553 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -22,6 +22,9 @@ autoincrement # auto use (from pytest library) autouse +# library for password hashing +bcrypt + # class method (pylint spellcheck) classmethod @@ -55,9 +58,18 @@ fget # full match (used by `re` library) fullmatch +# function +func + +# generate salt +gensalt + # get fixture value (used by `pytest` library) getfixturevalue +# hash password +hashpw + # health check healthcheck @@ -76,6 +88,9 @@ minioadmin # MyPy - static type checker for python (pylint spellcheck) mypy +# sqlalchemy column property nullable +nullable + # OpenAPI Specification openapi