From c27175a6a47c260d4b2b2afe1a3d1bdfb625bdc4 Mon Sep 17 00:00:00 2001 From: Alex Goryev Date: Sun, 20 Aug 2023 16:12:08 +0300 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 Добавлены модели базы данных - Акканут, Профиль, Хэш пароля и базовый функционал по созданию сущностей. --- .mypy.ini | 7 +- .ruff.toml | 1 + alembic.ini | 2 + ...ccount_profile_and_password_hash_tables.py | 65 ++++++++ poetry.lock | 82 +++++++++- pyproject.toml | 3 + src/account/exceptions.py | 16 ++ src/account/models.py | 87 ++++++++++ src/account/schemas.py | 5 + src/app.py | 21 +++ src/auth/controllers.py | 28 ++++ src/auth/routes.py | 10 +- src/auth/schemas.py | 8 +- src/profile/models.py | 45 +++++ src/profile/schemas.py | 5 + src/shared/database.py | 1 + src/shared/datetime.py | 27 +++ src/shared/exceptions.py | 10 ++ tests/conftest.py | 13 +- tests/test_auth/__init__.py | 0 tests/test_auth/conftest.py | 14 ++ tests/test_auth/test_create_account.py | 154 ++++++++++++++++++ tests/utils/mocks/__init__.py | 1 + tests/utils/mocks/bcrypt.py | 11 ++ tests/utils/mocks/models.py | 18 ++ whitelist.txt | 37 +++++ 26 files changed, 660 insertions(+), 11 deletions(-) create mode 100644 migrations/versions/2023_11_05_1332-d7454b0101c0_add_account_profile_and_password_hash_tables.py create mode 100644 src/account/exceptions.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 src/shared/datetime.py create mode 100644 tests/test_auth/__init__.py create mode 100644 tests/test_auth/conftest.py create mode 100644 tests/test_auth/test_create_account.py create mode 100644 tests/utils/mocks/__init__.py create mode 100644 tests/utils/mocks/bcrypt.py create mode 100644 tests/utils/mocks/models.py diff --git a/.mypy.ini b/.mypy.ini index 3ee4830b..fa6eb319 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -1,6 +1,11 @@ [mypy] -files = src +files = src,tests + +exclude = (?x)( + ^tests/test.*$ # skip tests + | ^tests/conftest.*$ # skip fixtures + ) plugins = pydantic.mypy diff --git a/.ruff.toml b/.ruff.toml index 5929a551..bcf33b11 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -2,6 +2,7 @@ ignore = [ "D203", # ignores D203 (one-blank-line-before-class) since we use D211 (no-blank-line-before-class) "D213", # ignores D213 (multi-line-summary-second-line) since we use D212 (multi-line-summary-first-line) "S101", # ignores S101 since we don't use python optimizations with "-O" + "RSE102", # ignores RSE102 (unnecessary-paren-on-raise-exception) since we think that empty parens is more clear ] line-length = 120 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_11_05_1332-d7454b0101c0_add_account_profile_and_password_hash_tables.py b/migrations/versions/2023_11_05_1332-d7454b0101c0_add_account_profile_and_password_hash_tables.py new file mode 100644 index 00000000..498cf1fc --- /dev/null +++ b/migrations/versions/2023_11_05_1332-d7454b0101c0_add_account_profile_and_password_hash_tables.py @@ -0,0 +1,65 @@ +"""add account, profile and password_hash tables + +Revision ID: d7454b0101c0 +Revises: 8f2f6221d1de +Create Date: 2023-11-05 13:32:37.606097+00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d7454b0101c0" +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(timezone=True), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("login", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("login"), + ) + op.create_table( + "password_hash", + sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("value", sa.LargeBinary(), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + ), + sa.PrimaryKeyConstraint("account_id"), + ) + op.create_table( + "profile", + sa.Column("account_id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("avatar", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["account_id"], + ["account.id"], + ), + sa.PrimaryKeyConstraint("account_id"), + sa.UniqueConstraint("avatar"), + ) + # ### 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..fc196610 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" @@ -305,6 +340,24 @@ files = [ [package.extras] graph = ["objgraph (>=1.7.2)"] +[[package]] +name = "dirty-equals" +version = "0.6.0" +description = "Doing dirty (but extremely useful) things with equals." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "dirty_equals-0.6.0-py3-none-any.whl", hash = "sha256:7c29af40193a862ce66f932236c2a4be97489bbf7caf8a90e4a606e7c47c41b3"}, + {file = "dirty_equals-0.6.0.tar.gz", hash = "sha256:4c4e4b9b52670ad8b880c46734e5ffc52e023250ae817398b78b30e329c3955d"}, +] + +[package.dependencies] +pytz = ">=2021.3" + +[package.extras] +pydantic = ["pydantic (>=1.9.1)"] + [[package]] name = "dlint" version = "0.14.0" @@ -592,6 +645,21 @@ files = [ [package.dependencies] flake8 = ">3.0.0" +[[package]] +name = "freezegun" +version = "1.2.2" +description = "Let your Python tests travel through time" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "greenlet" version = "2.0.2" @@ -1602,6 +1670,18 @@ files = [ [package.extras] dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==1.7.3)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + [[package]] name = "pyyaml" version = "6.0" @@ -2260,4 +2340,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.11" -content-hash = "463abf599ff2f419956b84060cf788af40ed8575fc4a6176488eff642354183c" +content-hash = "b418970051fa1b72b8a5db17449844985a5e14b3f526dc02b151cbc5f9c5fa28" diff --git a/pyproject.toml b/pyproject.toml index a695c4b9..d442ec57 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" @@ -72,6 +73,8 @@ optional = true [tool.poetry.group.test.dependencies] +dirty-equals = "0.6.0" +freezegun = "1.2.2" 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/exceptions.py b/src/account/exceptions.py new file mode 100644 index 00000000..6c700d19 --- /dev/null +++ b/src/account/exceptions.py @@ -0,0 +1,16 @@ +"""Exceptions related to account functionality.""" + +from __future__ import annotations + +from src.shared.exceptions import BadRequestException + + +class DuplicateAccountException(BadRequestException): + """Exception raised when such account already exists.""" + + action = "create account" + + description = "Account already exists." + details: str = ( + "Can not create an account because there is another account with same value for one of the unique fields." + ) diff --git a/src/account/models.py b/src/account/models.py new file mode 100644 index 00000000..fde4a921 --- /dev/null +++ b/src/account/models.py @@ -0,0 +1,87 @@ +"""Database models for account.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +import bcrypt +from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, select, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.account.exceptions import DuplicateAccountException +from src.shared.database import Base +from src.shared.datetime import utcnow + + +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(timezone=True), default=utcnow, nullable=False) + 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(timezone=True), default=utcnow, nullable=False, onupdate=utcnow, + ) + + @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 DuplicateAccountException() + + 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 DuplicateAccountException() + + account = Account(**account_data.dict(exclude={"password"})) + session.add(account) + await session.flush() + return account + + +class PasswordHash(Base): # pylint: disable=too-few-public-methods + """Password hash database model.""" + + __tablename__ = "password_hash" + + account_id: Mapped[int] = mapped_column( + ForeignKey("account.id"), + primary_key=True, # one account cannot have more than one password hash + autoincrement=False, + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow, + ) + value: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) + + @staticmethod + async def create(session: AsyncSession, password: Password, account_id: int) -> PasswordHash: + """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..dd9e2db1 100644 --- a/src/account/schemas.py +++ b/src/account/schemas.py @@ -19,6 +19,11 @@ class Account(Schema): email: Email login: Login + class Config: + """Pydantic's special class to configure pydantic models.""" + + orm_mode = True + class AccountId(Schema): """Account Id.""" diff --git a/src/app.py b/src/app.py index 2703363d..39506a38 100644 --- a/src/app.py +++ b/src/app.py @@ -2,9 +2,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from fastapi import FastAPI +from fastapi.responses import JSONResponse import src.routes +from src.shared.exceptions import BadRequestException + + +if TYPE_CHECKING: + from fastapi import Request app = FastAPI( @@ -23,3 +31,16 @@ ) app.include_router(src.routes.router) + + +@app.exception_handler(BadRequestException) +async def handle_bad_request(request: Request, exception: BadRequestException) -> JSONResponse: # noqa: ARG001 + """Handle BadRequestException.""" + return JSONResponse( + status_code=exception.status_code, + content={ + "action": exception.action, + "description": exception.description, + "details": exception.details, + }, + ) 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 e3142a2e..1e296033 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 +from src.auth import controllers, schemas 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 466f0546..2f21e4cf 100644 --- a/src/auth/schemas.py +++ b/src/auth/schemas.py @@ -18,7 +18,7 @@ DictT = TypeVar("DictT", bound=dict[str, Any]) -class _NewAccount(Schema): +class NewAccount(Schema): """Account data which is going to be created during sign up process.""" email: Email @@ -26,7 +26,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 @@ -36,8 +36,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..be94f3e5 --- /dev/null +++ b/src/profile/models.py @@ -0,0 +1,45 @@ +"""Database models for profile.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.shared.database import Base +from src.shared.datetime import utcnow + + +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" + + account_id: Mapped[int] = mapped_column( + ForeignKey("account.id"), + primary_key=True, # one account cannot have more than two profiles + autoincrement=False, + ) + + avatar: Mapped[str | None] = mapped_column(String, unique=True) + description: Mapped[String | None] = mapped_column(String) + name: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, onupdate=utcnow, + ) + + @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..0287c464 100644 --- a/src/profile/schemas.py +++ b/src/profile/schemas.py @@ -17,6 +17,11 @@ class Profile(Schema): description: Description | None name: Name + class Config: + """Pydantic's special class to configure pydantic models.""" + + orm_mode = True + class ProfileUpdate(Schema): """Data for Profile update.""" diff --git a/src/shared/database.py b/src/shared/database.py index f9e32f5d..e264e84c 100644 --- a/src/shared/database.py +++ b/src/shared/database.py @@ -55,4 +55,5 @@ async def get_session() -> AsyncIterator[AsyncSession]: # pragma: no cover await session.rollback() raise finally: + await session.commit() await session.close() diff --git a/src/shared/datetime.py b/src/shared/datetime.py new file mode 100644 index 00000000..3f21622d --- /dev/null +++ b/src/shared/datetime.py @@ -0,0 +1,27 @@ +"""Utilities for working with date and time.""" + +from __future__ import annotations + +from datetime import datetime, timezone + + +def utcnow() -> datetime: + """Get timezone aware datetime with UTC timezone. + + There are two reasons of having this function instead of just using `datetime.now(tz=timezone.utc)` everywhere: + + 1. We're using `freezgun` library which cannot mock datetime in sqlalchemy's column definitions, like: + ```python + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + ``` + because class level attributes are evaluated at compile time. This happens before `freezegun` + has patched datetime.datetime.now, so the column default functions still point to the stdlib implementation. + See this SO answer for details: https://stackoverflow.com/a/58776798/8431075 + + That's why we should wrap datetime.utcnow to another function. + + 2. Simply `utcnow()` is shorter than `datetime.now(tz=timezone.utc)` + + :returns: datetime + """ + return datetime.now(tz=timezone.utc) diff --git a/src/shared/exceptions.py b/src/shared/exceptions.py index 607489ad..3bf7c428 100644 --- a/src/shared/exceptions.py +++ b/src/shared/exceptions.py @@ -39,3 +39,13 @@ class NotAllowedException(HTTPException): description = "Requested action not allowed." details = "Provided tokens or credentials don't grant you enough access rights." status_code = status.HTTP_403_FORBIDDEN + + +class BadRequestException(HTTPException): + """Exception for 400 BAD REQUEST error.""" + + action: str + + description = "Request is not correct." + details = "Request is not correct and cannot be handled." + status_code = status.HTTP_400_BAD_REQUEST diff --git a/tests/conftest.py b/tests/conftest.py index 03229b01..1aa73fdb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,14 +4,18 @@ import asyncio from types import SimpleNamespace +from unittest.mock import patch +import bcrypt import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import async_scoped_session, async_sessionmaker, create_async_engine from src.app import app -from src.shared.database import get_session, POSTGRES_CONNECTION_URL +from src.shared.database import Base, get_session, POSTGRES_CONNECTION_URL from src.shared.minio import get_minio +from tests.utils.mocks.bcrypt import gensalt +from tests.utils.mocks.models import __eq__ from tests.utils.database import set_autoincrement_counters @@ -47,7 +51,12 @@ async def db_empty(): transaction = await connection.begin() async_session = async_scoped_session(async_sessionmaker(bind=connection), scopefunc=asyncio.current_task) - yield async_session + # TODO: move it to different place + with ( + patch.object(Base, "__eq__", __eq__), + patch.object(bcrypt, "gensalt", gensalt), + ): + yield async_session await async_session.close() await transaction.rollback() 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..f6e05096 --- /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@mail.com", login="john_doe") + session.add(account) + await session.commit() + return session diff --git a/tests/test_auth/test_create_account.py b/tests/test_auth/test_create_account.py new file mode 100644 index 00000000..e2485428 --- /dev/null +++ b/tests/test_auth/test_create_account.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import bcrypt +import dirty_equals +import pytest +from freezegun import freeze_time +from sqlalchemy import select + +from src.account.models import Account, PasswordHash +from src.profile.models import Profile +from tests.utils.mocks.bcrypt import SALT + + +@freeze_time("2023-10-30T20:00:00.000000") +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_empty"}) +async def test_create_account_returns_201_with_correct_body(f): + result = await f.client.post( + "/accounts", + json={ + "account": { + "email": "john.doe@mail.com", + "login": "john_doe", + "password": "qwerty123", + }, + "profile": { + "name": "John Doe", + "description": "I'm the best guy for your mocks.", + }, + }, + ) + + assert result.status_code == 201 + assert result.json() == { + "account": { + "id": dirty_equals.IsPositiveInt, + "created_at": "2023-10-30T20:00:00.000000Z", + "email": "john.doe@mail.com", + "login": "john_doe", + }, + "profile": { + "account_id": dirty_equals.IsPositiveInt, + "avatar": None, + "description": "I'm the best guy for your mocks.", + "name": "John Doe", + }, + } + + +@freeze_time("2023-10-30T20:00:00.000000") +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_empty"}) +async def test_create_account_creates_objects_in_db_correctly(f): + result = await f.client.post( # noqa: F841 + "/accounts", + json={ + "account": { + "email": "john.doe@mail.com", + "login": "john_doe", + "password": "qwerty123", + }, + "profile": { + "name": "John Doe", + "description": "I'm the best guy for your mocks.", + }, + }, + ) + + accounts = (await f.db.execute(select(Account))).scalars().all() + assert accounts == [ + Account( + created_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc), + email="john.doe@mail.com", + id=dirty_equals.IsPositiveInt, + login="john_doe", + updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc), + ), + ] + profiles = (await f.db.execute(select(Profile))).scalars().all() + assert profiles == [ + Profile( + account_id=dirty_equals.IsPositiveInt, + avatar=None, + description="I'm the best guy for your mocks.", + name="John Doe", + updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc), + ), + ] + password_hashes = (await f.db.execute(select(PasswordHash))).scalars().all() + assert password_hashes == [ + PasswordHash( + account_id=dirty_equals.IsPositiveInt, + updated_at=datetime(2023, 10, 30, 20, 0, tzinfo=timezone.utc), + value=bcrypt.hashpw(b"qwerty123", SALT), + ), + ] + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_with_one_account"}) +async def test_create_account_with_already_existed_email_returns_400_with_correct_body(f): + result = await f.client.post( + "/accounts", + json={ + "account": { + "email": "john.doe@mail.com", + "login": "john_doe-unique", + "password": "qwerty123", + }, + "profile": { + "name": "John Doe", + "description": "I'm the best guy for your mocks.", + }, + }, + ) + + assert result.status_code == 400 + assert result.json() == { + "action": "create account", + "description": "Account already exists.", + "details": ( + "Can not create an account because there is another account with same value for one of the unique fields." + ), + } + + +@pytest.mark.anyio +@pytest.mark.fixtures({"client": "client", "db": "db_with_one_account"}) +async def test_create_account_with_already_existed_login_returns_400_with_correct_body(f): + result = await f.client.post( + "/accounts", + json={ + "account": { + "email": "john.doe-unique@mail.com", + "login": "john_doe", + "password": "qwerty123", + }, + "profile": { + "name": "John Doe", + "description": "I'm the best guy for your mocks.", + }, + }, + ) + + assert result.status_code == 400 + assert result.json() == { + "action": "create account", + "description": "Account already exists.", + "details": ( + "Can not create an account because there is another account with same value for one of the unique fields." + ), + } diff --git a/tests/utils/mocks/__init__.py b/tests/utils/mocks/__init__.py new file mode 100644 index 00000000..09b781e7 --- /dev/null +++ b/tests/utils/mocks/__init__.py @@ -0,0 +1 @@ +"""Package with utils used for mocking.""" diff --git a/tests/utils/mocks/bcrypt.py b/tests/utils/mocks/bcrypt.py new file mode 100644 index 00000000..7c5b8399 --- /dev/null +++ b/tests/utils/mocks/bcrypt.py @@ -0,0 +1,11 @@ +"""Things used to mock bcrypt related functionality.""" + +from __future__ import annotations + + +SALT = b"$2b$12$BWuyUZeykvvLwwBEw/hq9u" + + +def gensalt() -> bytes: + """Mock for `gensalt` function from `bcrypt` module.""" + return SALT diff --git a/tests/utils/mocks/models.py b/tests/utils/mocks/models.py new file mode 100644 index 00000000..bac33639 --- /dev/null +++ b/tests/utils/mocks/models.py @@ -0,0 +1,18 @@ +"""Things used to mock models related functionality.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from src.shared.database import Base + + +def __eq__(self: Base, other: Base) -> bool: # noqa: N807 + for attr in self.__dict__: + if attr.startswith("_"): + continue # skip internal attributes used by sqlalchemy + if getattr(self, attr) != getattr(other, attr): + return False + return True diff --git a/whitelist.txt b/whitelist.txt index a34ce0b5..db10ccc1 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -22,12 +22,18 @@ autoincrement # auto use (from pytest library) autouse +# library for password hashing +bcrypt + # class method (pylint spellcheck) classmethod # configuration component config +# package from python standard library (pylint spellcheck) +datetime + # designates files with `.env` extension dotenv @@ -46,6 +52,9 @@ env # environments envs +# equals +eq + # fastapi framework fastapi @@ -55,9 +64,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 +94,12 @@ minioadmin # MyPy - static type checker for python (pylint spellcheck) mypy +# sqlalchemy column property nullable +nullable + +# on update +onupdate + # OpenAPI Specification openapi @@ -109,10 +133,14 @@ sessionstart # python library sqlalchemy +sqlalchemy's # source src +# standard library +stdlib + # table name tablename @@ -122,15 +150,24 @@ testclient # temporary tmp +# time zone +tz + # utilities utils +# function from `datetime` python package (pylint spellcheck) +utcnow + # validator validator # validators validators +# unit-test (python library) +unittest + # whitespaces whitespaces