From 64b2ad9dcf43b2dedabf334e3b642875a14c64d7 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Fri, 27 Jun 2025 00:56:59 +0300 Subject: [PATCH 01/11] feat: Create tests. --- .coverage | Bin 0 -> 69632 bytes .pre-commit-config.yaml | 8 +- .pylintrc | 9 +- auth_app/.coverage | Bin 0 -> 53248 bytes poetry.lock | 101 +++++++++++++++++- pyproject.toml | 4 +- tests/config/__init__.py | 0 tests/config/test_config.py | 47 ++++++++ tests/conftest.py | 38 +++++++ tests/general/__init__.py | 0 tests/general/test_messages.py | 14 +++ tests/services/__init__.py | 0 tests/services/utils/__init__.py | 0 tests/services/utils/test_general_services.py | 41 +++++++ tests/services/utils/test_jwt_handler.py | 65 +++++++++++ 15 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 .coverage create mode 100644 auth_app/.coverage create mode 100644 tests/config/__init__.py create mode 100644 tests/config/test_config.py create mode 100644 tests/conftest.py create mode 100644 tests/general/__init__.py create mode 100644 tests/general/test_messages.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/utils/__init__.py create mode 100644 tests/services/utils/test_general_services.py create mode 100644 tests/services/utils/test_jwt_handler.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..b32134723f45a1bb9b51a6e20c5335d8f4279ab4 GIT binary patch literal 69632 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 literal 0 HcmV?d00001 diff --git a/poetry.lock b/poetry.lock index 910274a..be336ef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -640,6 +640,86 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.9.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, + {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, + {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, + {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, + {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, + {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, + {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, + {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, + {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, + {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, + {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, + {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, + {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, + {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, + {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, + {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, + {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, + {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, + {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, + {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, + {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, + {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, + {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, + {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, + {file = "coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951"}, + {file = "coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed"}, + {file = "coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d"}, + {file = "coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244"}, + {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, + {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, + {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "cryptography" version = "45.0.3" @@ -2020,6 +2100,25 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "1.0.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3"}, + {file = "pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2641,4 +2740,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "dd477df8f21fbaafc2578a69f88c344a9311413fb4e74c88d4219f49f1ab7d5e" +content-hash = "afcd1559e03ec5f9a9bb5eca52da6f05f121babef428ada191a2413a5f36bac6" diff --git a/pyproject.toml b/pyproject.toml index 0e834ce..8e3eb47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,9 @@ dependencies = [ "passlib[bcrypt] (>=1.7.4,<2.0.0)", "pydantic-settings (>=2.9.1,<3.0.0)", "pytest (>=8.4.0,<9.0.0)", - "bcrypt (<4.1.0)" + "bcrypt (<4.1.0)", + "coverage (>=7.9.1,<8.0.0)", + "pytest-asyncio (>=1.0.0,<2.0.0)" ] [tool.poetry] diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..faf905a --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,47 @@ +from pydantic_settings import BaseSettings + +from auth_app.config import ( + AWSSettings, + BaseConfig, + JWTSettings, + PasswordSettings, + PostgresSettings, + RedisSettings, +) +from tests.conftest import ( + fake_env, +) + + +def test_base_conf() -> None: + assert issubclass(BaseConfig, BaseSettings) + + +def test_pg_settings(fake_env) -> None: + pg_settings = PostgresSettings() + assert pg_settings.postgres_dsn == "postgresql+asyncpg://test_user:test_pwd@localhost:5439/test_db" + + +def test_redis_settings(fake_env) -> None: + redis_settings = RedisSettings() + assert redis_settings.redis_dsn == "redis://:test_pwd@localhost:6380/0" + + +def test_jwt_settings(fake_env) -> None: + jwt_settings = JWTSettings() + assert str(jwt_settings.jwt_key) == "123" + assert jwt_settings.ADMIN_SECRET.get_secret_value() == "test_secret" + assert jwt_settings.ALGORITHM.get_secret_value() == "test_alg" + + +def test_pwd_settings(fake_env) -> None: + pwd_settings = PasswordSettings() + assert pwd_settings.HASHING_ALGORITHM.get_secret_value() == "test_alg" + assert pwd_settings.HASHING_DEPRECATED.get_secret_value() == "test_depr" + + +def test_aws_settings(fake_env) -> None: + aws_settings = AWSSettings() + assert aws_settings.AWS_ENDPOINT == "http://localhost:4566" + assert aws_settings.AWS_DEFAULT_REGION == "default" + assert aws_settings.AWS_SECRET_ACCESS_KEY.get_secret_value() == "test_secret" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..080f1ab --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest + + +@pytest.fixture +def fake_env(monkeypatch): + # POSTGRES DB + monkeypatch.setenv("POSTGRES_USER", "test_user") + monkeypatch.setenv("POSTGRES_PASSWORD", "test_pwd") + monkeypatch.setenv("POSTGRES_DB", "test_db") + monkeypatch.setenv("POSTGRES_HOST", "localhost") + monkeypatch.setenv("POSTGRES_PORT", "5439") + + # REDIS + monkeypatch.setenv("REDIS_PASSWORD", "test_pwd") + monkeypatch.setenv("REDIS_PORT", "6380") + monkeypatch.setenv("REDIS_HOST", "localhost") + + # PASSWORD HASHING + monkeypatch.setenv("HASHING_ALGORITHM", "test_alg") + monkeypatch.setenv("HASHING_DEPRECATED", "test_depr") + + # JWT SECRET KEY + monkeypatch.setenv("KEY", "123") + monkeypatch.setenv("ALGORITHM", "test_alg") + monkeypatch.setenv("REFRESH_LASTING", "3500") + monkeypatch.setenv("ACCESS_LASTING", "500") + monkeypatch.setenv("ADMIN_SECRET", "test_secret") + + # LOCALSTACK + monkeypatch.setenv("SERVICES", "ses, s3") + monkeypatch.setenv("AWS_DEFAULT_REGION", "default") + monkeypatch.setenv("LOCALSTACK_HOST", "localstack") + monkeypatch.setenv("DEBUG", "1") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test_key") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test_secret") + monkeypatch.setenv("AWS_ENDPOINT", "http://localhost:4566") + monkeypatch.setenv("RESET_PWD_LENGTH", "15") + monkeypatch.setenv("VERIFICATION_CODE_LENGTH", "5") diff --git a/tests/general/__init__.py b/tests/general/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/general/test_messages.py b/tests/general/test_messages.py new file mode 100644 index 0000000..ed69d56 --- /dev/null +++ b/tests/general/test_messages.py @@ -0,0 +1,14 @@ +from auth_app.messages.common import msg_creator + + +def test_messages() -> None: + assert msg_creator.get_code_message("123") == f"Your verification code was sent to 123." + assert msg_creator.get_reset_pwd_message() == "Password was changed. Check your email to get it." + assert msg_creator.get_root_description() == "Auth REST API for the Skill Tracker Application" + assert msg_creator.get_root_title() == "Skill Tracker Auth Service" + ses_reset_pwd_msg = msg_creator.get_ses_reset_pwd_message("123456") + assert isinstance(ses_reset_pwd_msg, dict) + assert ses_reset_pwd_msg["subject"] == "Auth service: Password reset" + ses_confirmation_message = msg_creator.get_ses_confirmation_message("12345") + assert isinstance(ses_confirmation_message, dict) + assert ses_confirmation_message["subject"] == "Auth service: Verification code" diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/utils/__init__.py b/tests/services/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/utils/test_general_services.py b/tests/services/utils/test_general_services.py new file mode 100644 index 0000000..f6c1b6c --- /dev/null +++ b/tests/services/utils/test_general_services.py @@ -0,0 +1,41 @@ +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from auth_app.repositories.users import UserRepo +from auth_app.schemes.users import GetUserScheme, RoleEnum +from auth_app.services.utils.authenticate_user import authenticate_user +from auth_app.services.utils.pwd_hashing import ( + hash_password, + verify_password, +) + +email = "email@example.com" +pwd = "12345Password!" +hashed_pwd = hash_password(pwd) + + +def test_hash_password() -> None: + hashed_password = hash_password(pwd) + assert isinstance(hashed_password, str) + + +def test_verify_password() -> None: + assert verify_password(pwd, hashed_pwd) + + +@pytest.mark.asyncio +async def test_authenticate_user() -> None: + mock_user = GetUserScheme( + email=email, + password_hash=hashed_pwd, + id=UUID("150881a3-c874-4a93-92d2-6be10a4c189c"), + role=RoleEnum.USER, + is_verified=False, + is_active=True, + ) + mock_repo: UserRepo = AsyncMock(spec=UserRepo) + mock_repo.get_users.return_value = [mock_user] + result = await authenticate_user(email, pwd, mock_repo) + assert isinstance(result, GetUserScheme) diff --git a/tests/services/utils/test_jwt_handler.py b/tests/services/utils/test_jwt_handler.py new file mode 100644 index 0000000..ee4eb07 --- /dev/null +++ b/tests/services/utils/test_jwt_handler.py @@ -0,0 +1,65 @@ +from uuid import UUID + +from auth_app.schemes.tokens import CreateDataScheme +from auth_app.schemes.users import RoleEnum +from auth_app.services.utils.jwt_handler import JWTHandler + +jwt_handler = JWTHandler() +create_data = CreateDataScheme( + user_id=UUID("123e4567-e89b-12d3-a456-426614174000"), + email="mail@example.com", + role=RoleEnum.USER, +) +extra_payload = { + "test": "test", +} +token = jwt_handler.generate_refresh(create_data=create_data)["refresh_token"] +access_token = jwt_handler.generate_access( + refresh_token=token, + extra_payload=extra_payload, +)["access_token"] +payload = jwt_handler.base_decode(token) +access_payload = jwt_handler.base_decode(access_token) + + +def test_get_refresh_response() -> None: + response = JWTHandler.get_refresh_response( + token=token, + payload=payload, + ) + assert isinstance(response, dict) + assert response["refresh_token"] == token + assert response["payload"] == payload + + +def test_get_access_response() -> None: + response = JWTHandler.get_access_response( + token=access_token, + ) + assert response["access_token"] == access_token + assert isinstance(response, dict) + + +def test_generate_refresh() -> None: + refresh_response = jwt_handler.generate_refresh( + create_data=create_data, + ) + assert isinstance(refresh_response, dict) + assert isinstance(refresh_response["refresh_token"], str) + assert isinstance(refresh_response["payload"], dict) + assert refresh_response["payload"]["email"] == "mail@example.com" + + +def test_generate_access() -> None: + access_response = jwt_handler.generate_access( + refresh_token=token, + extra_payload=extra_payload, + ) + assert isinstance(access_response, dict) + assert isinstance(access_response["access_token"], str) + + +def test_base_decode() -> None: + refresh_payload = JWTHandler.base_decode(token) + assert isinstance(refresh_payload, dict) + assert refresh_payload["token_type"] == "refresh" From 21ab00ff1b3c7f7acc9a444fec8c3d338d4c91b5 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Mon, 30 Jun 2025 00:16:10 +0300 Subject: [PATCH 02/11] test: Adding new tests --- .coverage | Bin 69632 -> 53248 bytes poetry.lock | 20 ++- pyproject.toml | 3 +- tests/conftest.py | 32 +++++ tests/services/test_ses.py | 73 ++++++++++ tests/services/test_tokens.py | 0 tests/services/test_users.py | 149 +++++++++++++++++++++ tests/services/utils/test_token_handler.py | 82 ++++++++++++ 8 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 tests/services/test_ses.py create mode 100644 tests/services/test_tokens.py create mode 100644 tests/services/test_users.py create mode 100644 tests/services/utils/test_token_handler.py diff --git a/.coverage b/.coverage index b32134723f45a1bb9b51a6e20c5335d8f4279ab4..dec57bb98925090dc2451bb765455f44a987447e 100644 GIT binary patch delta 2367 zcmZ8jdr(x@8Q*jE-n;ki-Fwc$!m_;f0rFg6SHX@Zk=MeqyC?!lP*GX!h-eZq3LWZL zgwC`XZ8E-2CRQV&Ns~#fV@gOTV|-;MiOFPI*8I^+Y~UpIH;qa(fu?)X2=EdGqOY( zQ_d*Oip4Z!deu}TPs#7gTV=cP8zVCYr77t>X}u(i*TfFdYZx{B#<0;~6NZEXLM1=N z5Ar+s9B!C8OacAla};W-bd`*|UA-Fce5KgwG>cfQ!# z9^SUOealO&yNh1lQ(pw(Ean<5W#{}96{@6s<{UGyNQeN#f}0AMxu!{2HELcj}_1y?(O;$@!IR^AS?Nvxu-04xLRUM*vPI+QMEGgTH`k;bBdVs@#D z2o0%_m;)j!~gL3C!6QpRJ+1 z6)cCg@S7nS2NGe}V&)Dh>|RjBzJ&iZ2%B-01(s0^z2Z?kjOG_=;(!ec9rdUb>BpNp zTH)eBGu!ObiqM4(*V4Hm)J&UDNx+me;!wbh6u4a_GA%S43K-aipb^Qio!R@To~BiA zV!x>EMD`~j1B@DAy>%;a6|c=zKWbFwpENf&*fMr5CgV~)yINPu_RswE=y%)}FAnKw zkaUZS4R=Vfa#wjvS+69RUNc2Z0eM<(lYegv$qw-g;|=5MR3~kaOypx}T7LpRkba9p zVkiCF@Q$H{9xx;e5#d*YkDum0o7_AiRe zy!Uwy#DAvw8CCpo|1W;e(eVaU1gvLKA){;7@C9o*(D+a=5?0ITK)n={h zz`7OY#}-Dl>+NdrvY`M)1TIqv0(Y&e0)dy^D*-P^dE?22F0$W(M3^ebdRt1hDL}~L zoRE2FUBc=|B~{0WV^>lhD`33y$caRrJ3#lM7(JS}i zKj6CZrE*ajfd7J5l|9O~x8ux85A||La(Yv4zjk-&)Pp;jU1kqBGCb|#zR1MH;J;7y z>l2E=lV9|slehZ&hhvlB(+!i;-G~%|*IxMX&Ys~57e@Z17Yo3SFL-L^oa}~^&Ha~8 zmrq3XLOvKIAKUT(m*jox>p2+hyE@W!btrbPFC25-O+u~+mD8Kj0H`fShsv`}HsetjR ze*1S~ih4>hUfP?O?1y7P@+*B)zq9LfDe2MvYm*PJ91?6Q$A-luH8W}K*GRCc9xEn^ zfc8Xd-NSNyxliBeNC3M$fw>N)_E_NPwG@e^!JXr}Ps217(iGi*2CZhva)QM*czm1_~Y`Uyx7Wo8^PSICF?Rr;dcYUJX1R4T_31 mC@j>Vpg;q+TZ8<34f66d$j#LtCr5+qYz?xq28I49?|%WG($M|@ delta 5181 zcmeHLdvH|M8NcT~&fdq~bM}>GlTETo2p|vhAR!4L1hRQ;5)z;fP|+oj4Qzn~Nf4Qc zVMnK8rz++;4rna=(`jqb7MKx%qMfmiscqTVsaV^ZOvg6Z&N#GKz>q2JckbqHt_if$ zsr4_CnfsmdednC-anA32-=6(Ph5bjxgF#8-IL`e{UNzE=Z#-JvFF_a4arkm>I!^vU z&XN6Oss1}iq_fe=0)CJ@twUr8=}#8SG+eGW&Bd$mu2kg-rWn6?UHbbG1OA#u_U`E7OyRy zP^Wk;QCdl)$T;2{;UjkfDa+mw@95Yb+ta=)W?!~>0r(I!o@pt`oRoKLiuS~Mdg9$( z%%S;d4cpo~x3$}6H_kJL>X~bb5o%T4Tid%ph3WLlxv;JSVpHtu*kqqle+To>$Z7QQ z;W^ADXliQ5CeXBaw!rOWra0Th9Z18%7~=G|}>UL8tL8I5E}xzG!l z!m1`W-;iUVwg$c-8%UW^+@kB7+IwQm%pijvMFDgBt5*g2 z#u;g2VLq`&H~V3MASfz^WdnF!26W4&n5r3-wLZxQ5_aJ1PGDnslg8J2VVCNV2XvJf z3+nZ>DPgx6WW8B7hME-QGB@*$Cb#V@QV1q&B!p!_y`K*gYl0fv(H`Fp9@gtHqMomF zrdPKlVGRabb7iA#)eC$hD5{Sjhb0(jkc|gg8}V`pj7O%I``R?VPK3qkB>|Xhi#I^i7afP;7kv-X>H8 z-|}04{mzs}fUv+Vz`h{8{4}i3HXQzw=OeyGE}FH@t84Afm%jx zbJqaWlc&uEv7B#V8c}PwVgZ+cyaDOyg)P1lMq2X#&-V?2ZxJM0TocUqN2F>oftWMJ z24ERUuuMG`t&N?)as`*`T+eRT}rdaAIa~@ugOoz8{~WBTV#+tOAbKFe~9cPon!-PAr+*E6r6Osex>)T zg(6b?g`J)C0fc8lo6O{W1wiK&R1E&%$dTboSE5JPTp8boNIr0L^Urkm51&4L@t1;} z2YpZ8l7AkPL#!40%OQkwp+$0eoWp1ikFy!gUaJOy`h!R_v7hn%yp{npkn!6$7k@k=U>{@pc`N74)3>OS2aTUqShtF*pesS-f zEyG)e4{!Mf3Q`bM(R&V#j9=}0;nKN%r^fb;JTfvmHoW5_EE5oVi28#*tV4_FyygTN za6XqDd4Eq5YmBM!TEEJ;>QBy|txi6T6~RTIeonuF}{+mnII zmJA^6ykp)kX}G4-0Zrf5WiYCdjVkeOzMT~Qx1^xfq7Ti0|J$ShqiRy4Zk80Z2f0Hj zDI^7)3*AVny9_S4&mlcH5!%BP+H=5vn;y~?rN@c5I<{JgL7JfwV>hprBPNfa%XuMn z1XDVp_`6l2z`A~!`}(ALEEU}{V@Tu%_jG#fKttd^qz??g>=18F(2eIB$n;#YjOCK0 z>G)h}qf3HvHb-Bh-L!yQAYUcb`k4NbzE0Kvz;OBciOYsdd;i;OnppUrd&~8 zQMM}C@|$uu+%3ZnX|0%;!?acz#~U;7;#-swpK;p$bXK;~`b}f$wADS@v^4CpNgYdt zJ}Z8zaV{#~Bp*w{5~0tkw6T4*<#?6xc#R)dO(~WpyJo>PrLvk*N=;Oc)63w=LsxdQ zQrdodrmJpLX;x-Xs8sY%zae!v50&uyoc@R&ptb+!=YYWU_W+g?Za)Xyeh#?(8~~3E t*8TOFKLCw*_Pt%5Fl*fFoKe*xYD{j~r9 diff --git a/poetry.lock b/poetry.lock index be336ef..b03192b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2119,6 +2119,24 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-mock" +version = "3.14.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2740,4 +2758,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "afcd1559e03ec5f9a9bb5eca52da6f05f121babef428ada191a2413a5f36bac6" +content-hash = "a8a81582fc3841b9ba4b511a716e17a6dba08666c47bf4d942b7d20ffa4501e0" diff --git a/pyproject.toml b/pyproject.toml index 8e3eb47..7245ddb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ dependencies = [ "pytest (>=8.4.0,<9.0.0)", "bcrypt (<4.1.0)", "coverage (>=7.9.1,<8.0.0)", - "pytest-asyncio (>=1.0.0,<2.0.0)" + "pytest-asyncio (>=1.0.0,<2.0.0)", + "pytest-mock (>=3.14.1,<4.0.0)" ] [tool.poetry] diff --git a/tests/conftest.py b/tests/conftest.py index 080f1ab..b325d49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,12 @@ +from unittest.mock import ( + AsyncMock, + Mock, +) + import pytest +from auth_app.services.users import UserService + @pytest.fixture def fake_env(monkeypatch): @@ -36,3 +43,28 @@ def fake_env(monkeypatch): monkeypatch.setenv("AWS_ENDPOINT", "http://localhost:4566") monkeypatch.setenv("RESET_PWD_LENGTH", "15") monkeypatch.setenv("VERIFICATION_CODE_LENGTH", "5") + + +@pytest.fixture +def mock_dependencies(): + return { + "user_repo": Mock( + create_user=AsyncMock(), + get_user=AsyncMock(), + update_user=AsyncMock(), + get_users=AsyncMock(), + ), + "token_repo": Mock(), + "redis": Mock(), + "ses": Mock(), + } + + +@pytest.fixture +def user_service(mock_dependencies): + return UserService( + user_repo=mock_dependencies["user_repo"], + token_repo=mock_dependencies["token_repo"], + redis=mock_dependencies["redis"], + ses=mock_dependencies["ses"], + ) diff --git a/tests/services/test_ses.py b/tests/services/test_ses.py new file mode 100644 index 0000000..8a8e8da --- /dev/null +++ b/tests/services/test_ses.py @@ -0,0 +1,73 @@ +from unittest.mock import ( + AsyncMock, + patch, +) + +import pytest +from aiobotocore.client import AioBaseClient + +from auth_app.config import aws_settings +from auth_app.db.connect_redis import redis_client +from auth_app.messages.common import msg_creator +from auth_app.schemes.email import EmailPayloadScheme +from auth_app.services.ses.ses_handler import ses_handler + + +def test_generate_email_payload() -> None: + email_payload = ses_handler.generate_email_payload( + message="Test message", + subject="Test subject" + ) + assert isinstance(email_payload, EmailPayloadScheme) + assert isinstance(email_payload.message, str) + assert isinstance(email_payload.subject, str) + + +def test_generate_otp() -> None: + code = ses_handler.generate_otp() + assert isinstance(code, str) + assert len(code) == aws_settings.VERIFICATION_CODE_LENGTH + + +@pytest.mark.asyncio +async def test_reset_password() -> None: + with patch.object(ses_handler, "generate_email_payload") as mock_gen_payload, \ + patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ + patch.object(ses_handler, "generate_otp") as mock_generate_otp: + mock_send_email.return_value = None + mock_generate_otp.return_value = "TestOTP123" + mock_gen_payload.return_value = EmailPayloadScheme( + message="Test message", + subject="Test subject", + source="sender@example.com", + ) + result = await ses_handler.reset_password( + email_to='email_to@example.com', + ses=AioBaseClient, + ) + assert isinstance(result, dict) + assert result["new_password"] == "TestOTP123" + + +@pytest.mark.asyncio +async def test_send_confirmation_email() -> None: + with patch.object(ses_handler, "generate_otp") as mock_generate_otp, \ + patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ + patch.object(msg_creator, "get_ses_confirmation_message") as mock_get_ses_message, \ + patch.object(redis_client, "set", new_callable=AsyncMock) as mock_set_redis: + mock_generate_otp.return_value = "TestOTP123" + mock_get_ses_message.return_value = { + "message": "test message", + "subject": "test subject", + "response_message": "test response", + } + mock_send_email.return_value = None + mock_set_redis.return_value = None + result = await ses_handler.send_confirmation_email( + email_to="email_to@example.com", + ses=AioBaseClient, + redis_client=redis_client, + ) + assert isinstance(result, dict) + assert isinstance(result["message"], str) + assert result["message"] == "test response" diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/test_users.py b/tests/services/test_users.py new file mode 100644 index 0000000..d817095 --- /dev/null +++ b/tests/services/test_users.py @@ -0,0 +1,149 @@ +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from auth_app.exeptions.custom import ServiceError +from auth_app.models import UserORM +from auth_app.models.users import UserRole +from auth_app.schemes.users import ( + CreateResponseScheme, + CreateUserExtendedScheme, + MessageResponseScheme, + RoleEnum, +) +from auth_app.services.users import UserService + +fake_user_orm = UserORM( + id=UUID("e79229a7-b91f-475a-a88d-4f3ed6e29da4"), + email="example@email.com", + password_hash="$2b$12$grLAzB0SQ1WSxd84K5lei.L", + role=UserRole.USER, + is_verified=False, + is_active=True, +) + +payload = { + "email": "example@email.com", + "user_id": "e79229a7-b91f-475a-a88d-4f3ed6e29da4", +} + + +@pytest.mark.asyncio +async def test_create_user_record_base(user_service, mock_dependencies) -> None: + user_data = CreateUserExtendedScheme( + email="example@email.com", + password_hash="1234PwdExample!", + role=RoleEnum.USER, + ) + mock_dependencies["user_repo"].create_user.return_value = fake_user_orm + user_orm = await user_service.create_user_record( + user_data=user_data, + ) + assert isinstance(user_orm, UserORM) + assert user_orm.role == RoleEnum.USER + assert user_orm.email == "example@email.com" + + +@pytest.mark.asyncio +async def test_create_user_record_admin(mock_dependencies) -> None: + admin_data = CreateUserExtendedScheme( + email="example@email.com", + password_hash="1234PwdExample!", + role=RoleEnum.ADMIN, + admin_code=None, + ) + user_service = UserService(**mock_dependencies) + with pytest.raises(ServiceError) as exc_info: + await user_service.create_user_record(user_data=admin_data) + assert "Invalid role or permission code" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_create_init_code_message(mocker, mock_dependencies): + user_data = fake_user_orm + mock_send = mocker.patch( + "auth_app.services.users.ses_handler.send_confirmation_email", + new_callable=AsyncMock, + ) + mock_msg = mocker.patch( + "auth_app.services.users.msg_creator.get_code_message", + return_value="Code sent", + ) + user_service = UserService(**mock_dependencies) + response = await user_service.create_init_code_message(user_data) + mock_send.assert_called_once_with( + email_to="example@email.com", + ses=mock_dependencies["ses"], + redis_client=mock_dependencies["redis"], + ) + mock_msg.assert_called_once_with("example@email.com") + + assert isinstance(response, CreateResponseScheme) + assert response.record.email == "example@email.com" + assert response.message == "Code sent" + + +@pytest.mark.asyncio +async def test_create_verification_code(mocker, mock_dependencies): + mock_send_email = mocker.patch( + "auth_app.services.ses.ses_handler.SesHandler.send_confirmation_email", + new_callable=AsyncMock, + return_value=None, + ) + mock_get_code_message = mocker.patch( + "auth_app.messages.common.MessageCreator.get_code_message", + return_value="Code sent", + ) + user_service = UserService(**mock_dependencies) + response = await user_service.create_verification_code( + payload=payload, + ) + mock_send_email.assert_called_once_with( + email_to="example@email.com", + ses=mock_dependencies["ses"], + redis_client=mock_dependencies["redis"], + ) + mock_get_code_message.assert_called_once_with("example@email.com") + assert isinstance(response, MessageResponseScheme) + assert response.message == "Code sent" + + +@pytest.mark.asyncio +async def test_execute_verification(mocker, mock_dependencies) -> None: + mock_verify_auth_code = mocker.patch( + "auth_app.services.users.verify_auth_code", + new_callable=AsyncMock, + return_value=True, + ) + mock_dependencies["user_repo"].update_user.return_value = fake_user_orm + + user_service = UserService(**mock_dependencies) + user_data = await user_service.execute_verification( + verification_code="123", + payload=payload, + ) + mock_verify_auth_code.aassert_called_once_with( + email="example@email.com", + code="123", + ) + assert isinstance(user_data, UserORM) + + +@pytest.mark.asyncio +async def test_reset_password(mocker, mock_dependencies) -> None: + mocker.patch( + "auth_app.services.ses.ses_handler.SesHandler.reset_password", + new_callable=AsyncMock, + return_value={ + 'message': "message", + 'new_password': "password", + }, + ) + mock_dependencies["user_repo"].update_user.return_value = fake_user_orm + user_service = UserService(**mock_dependencies) + response = await user_service.reset_password( + payload=payload, + ) + assert isinstance(response, dict) + assert response["message"] == "message" diff --git a/tests/services/utils/test_token_handler.py b/tests/services/utils/test_token_handler.py new file mode 100644 index 0000000..aab97be --- /dev/null +++ b/tests/services/utils/test_token_handler.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +import pytest +from fastapi.security import HTTPAuthorizationCredentials + +from auth_app.exeptions.custom import TokenError +from auth_app.services.utils.token_handler import ( + TokenData, + get_current_token_payload, + token_handler, +) +from tests.services.utils.test_jwt_handler import ( + payload, + token, +) + + +def test_get_current_token_payload() -> None: + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with patch.object(token_handler, "verify_refresh") as mock_verify_refresh: + mock_verify_refresh.return_value = TokenData( + token=token, + payload=payload, + ) + token_data = get_current_token_payload(credentials) + assert isinstance(token_data, TokenData) + assert token_data.token == token + assert token_data.payload == payload + + +def test_requre_expired() -> None: + token_data = token_handler.requre_expired(token) + assert isinstance(token_data, TokenData) + assert token_data.token == token + + +def test_requre_token() -> None: + with patch.object(token_handler, "decode_token") as mock_decode: + mock_decode.return_value = token_handler.requre_expired(token) + token_data = token_handler.requre_expired(token) + assert isinstance(token_data, TokenData) + assert token_data.token == token + + +def test_verify_refresh() -> None: + with patch.object(token_handler, "requre_token") as mock_requre_token: + mock_requre_token.return_value = TokenData( + token=token, + payload={ + "token_type": "abc", + } + ) + with pytest.raises(TokenError) as invalid_token: + token_handler.verify_refresh(token) + + assert "Invalid token type. Refresh token required." in str(invalid_token.value) + + +def test_verify_access() -> None: + with patch.object(token_handler, "requre_token") as mock_requre_token: + mock_requre_token.return_value = TokenData( + token=token, + payload={ + "token_type": "abc", + } + ) + with pytest.raises(TokenError) as invalid_access: + token_handler.verify_access(token) + assert "Invalid token type. Access token required." in str(invalid_access.value) + + +def test_verify_admin() -> None: + with patch.object(token_handler, "requre_token") as mock_requre_token: + mock_requre_token.return_value = TokenData( + token=token, + payload={ + "role": "USER", + } + ) + with pytest.raises(TokenError) as verify_admin_error: + token_handler.verify_admin(token) + assert "Invalid role type. Admin required." in str(verify_admin_error.value) From 6bc1dfd0aae085d62fbc6af5b75d6d20965aeba0 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Mon, 30 Jun 2025 15:55:56 +0300 Subject: [PATCH 03/11] tests: Add new tests for token service. --- .coverage | Bin 53248 -> 69632 bytes tests/conftest.py | 6 +- tests/services/test_tokens.py | 144 ++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/.coverage b/.coverage index dec57bb98925090dc2451bb765455f44a987447e..72c33d2a389e3c3424363411b4653d8a2711d32c 100644 GIT binary patch delta 2771 zcmeHJTToL+7(VypoRhN~0$48EFjy;uYY1`)SQsJ~g(9E`2oljmO|?NGj$^H2sxuv{ zof5a5YD)z>;{zjJ(}!ZK%(T*R z***LJ-+tfj?jCw~57QaTiUdJ~x#9{)@yzO;0>I*sgj4vN2!m4y6qd)n%M{S#^kKT1 zW~n}^o}$PL zq^%UB4J~%dCTm+eMogJ#sKS8el!=064UTgSG_f7ID$-RgHam`sG8n?iqp-zkVHWFV ztKDL?ueDj_k<0a{yJ$IAla3!q#1A}=hDtf4F6Pm0Q#4nXhSP?Sw1~17MX;$Di4H+H z^uiL6DoH_ccoI!=c^-UNOZ`#fivV zDp#rz9jzO!c6p_x35ZfyJH>v)+SvqVgs!1E>IBt5(JogW zvezcbG-zhfkc9?Ucp#YtRS*jc&|p*MGzpnTdOI0Jg2BW%c@o!{rVb$?10AWU<@C!K zXQGTh&Ov%T<1~b(We&o*v~tE79SV6!AXAaHF=++|Dbz(L|cvu9Gby0%MTaMJ-&HB_Bl&BU$KOGX0Tg+Id|;P-FT zVd#Q;;j3^9w82%d7M8(6m;<%oA-Dxb!F6y6oCj{u3yy+ApcCu?J3t$F39JHzKme5V zn{-9`R5~jimG(sZ$`@HJr8FOA?D}BjM&7{iPuoZ3mkB%F;)iWxmHK6!8Q=G<*c9ZqO;g~msH(TVtPX5uvn{urqk zSn(icWOUiU#NFQJTU1m6hB6XP>aX8<@NGfI{j2vMezWz$`;YobIA5M{{%7ucZ%&X} zNXZdZbam|FSiz&=zi5_Zcpgv!?yDXgM~coC3LJND=_~=1Uhh78q*C7@P&5%woFd?2 zuv4iI-S5(eQlp|;(IrKw4yXoIW#W{$UOX)<;dUuR?n_at{7Segyv=1nf^TAf`KMx# z#pV0By3(JfE+Rv9p>)t=q5E5$<=KvApYX6V{1?JeI+&$T77ni>x&y-DbT};agfo?c z)V0@GzijixwVP~KyF<3Og;=mznl{^JM|-g6=i_u{L|f}t2X@h5AWt@%FhAfoSz~}6 zKN8SxE;`+qIs0Xs1s@vQTH36^ljT$}&RCCEMNQ+DHRg6}d%LYwK0&hOb)Y%*bu2D! znYY$6^^FYQOH?6s!(kSjBaV90qYaUA3lS^SvjM7HHe%KD50~f(7AVIn$Ii(VbQhlQ8P#MMB;&IU;&KABC-VzoI1phI= zi4Ru}C~eptQj|~GOnHB=Q>M+Zl4A1c9dd=TcYBJ$=DD9S(mUi+*JJ4Fpp(3YanASm za26Zl!qXltcXB%NIQC-}&FLx`r`9`B&Pt7s`*5Z2#AW>k9v0XHi4yL07I6|O3Tytq ziu@~Ak=w$-)r>{~NCLXy1{e=+fOo))fW&)oT;i52^xc#4RnuB8$;UUO1>rNqUXq9N xhDg73gEoxGC|~e2e#E~87v%aNSC0Sy delta 674 zcmYk4T}YEr7{~XX_kHI!J3=hWH;rpr_a)SwF~D5?vIvKdqa znK%y=iXST>D9V`YMuQCMqKnFI3JlcjqB*UG&Rv+nI)X%3&-wrU&w<~8(-o1rB4TfW zY|v;lG_1GVX%x9|vqiU8h~{Njq(FIJNj86v)DctIdRmHqSvwGA#w zbIrldCl0dAqRhq?joLaVM|;?cx17y-QFicT*;>dWjdtd#Y9viIHdfgzTY0yGcQvHO z!jyouZ#j!as&%Z}OUdD4CLH}o%9bEzPR|(nzDh=w_%-N*PF+b>0`}2mW~wX7fl2*_ zI6&6)kBFbu*6lJ#_oRThD%OzUX!x7JrWS2X@#vXSRVb^+CGH#zdNzG!SG~k_qe1gl zk6arBC%vaA9O*E}3qP)TcK(1#oa`a!j5J z%}q?i--|js&)e)}nJ3vce#*CfeqEZH7qwO%z)JEheD1Y;@rHWlho<5kQy-GQhFX(( ztJ4MA;?0#}f@VGy^Uc`$oups_!jp+q&cxrv-I*S@EJ%7ikj+1+!Z^$7JsHb2`XM$x HTwC%N!3yFm diff --git a/tests/conftest.py b/tests/conftest.py index b325d49..75c99a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,11 @@ def mock_dependencies(): update_user=AsyncMock(), get_users=AsyncMock(), ), - "token_repo": Mock(), + "token_repo": Mock( + create_refresh=AsyncMock(), + get_refresh=AsyncMock(), + update_refresh=AsyncMock(), + ), "redis": Mock(), "ses": Mock(), } diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index e69de29..3ba72b4 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -0,0 +1,144 @@ +from datetime import datetime, timedelta +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from auth_app.models import RefreshTokenORM +from auth_app.models.users import ( + UserORM, + UserRole, +) +from auth_app.schemes.tokens import RoleDataScheme +from auth_app.schemes.users import ( + AuthUserScheme, + GetUserScheme, + RoleEnum, +) +from auth_app.services.tokens import TokenService +from auth_app.services.users import UserService +from auth_app.services.utils.token_handler import TokenData + +auth_data = AuthUserScheme( + **{ + "email": "example@email.com", + "password_hash": "examplePassword123!", + } +) +refresh_orm = RefreshTokenORM( + id=UUID("cfe82c6f-7d8c-4172-bc33-04a24a065c20"), + user_id=UUID("8c024e52-38f2-4823-90e7-5817c8b4a7ab"), + token="eyJhbGciOiJIUzI1NiIkpXVCJ9.eyJ1cmcmVzaCJ9.mvbiRt8fK2yiQXER-PiYdk465IdDjg0cry4", + expires_at=datetime.now(), +) +get_user = GetUserScheme( + **{ + "email": "example@email.com", + "password_hash": "$2b$12$WvfCqCmMM2xN4O4d8uVTnOANVCQXg0rwtYxiTi2KT8NxYLcpwNC/O", + "id": UUID("8c024e52-38f2-4823-90e7-5817c8b4a7ab"), + "role": RoleEnum.USER, + "is_verified": False, + "is_active": True, + } +) +test_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiOGMwMjRlNTItMzhmMi00ODIzLTkwZTctNTgxN2M4YjRhN2FiIiwiZW1haWwiOiJqb2UuMDEwMUBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiZXhwaXJlcyI6MTc1MDQyMzAyOC4xNzI2NzczLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.mvbiRt8fK2yiQXER-PiYdk465IdDjg0NRxcKRLXcry4" + +token_data = TokenData( + token=test_token, + payload={ + "user_id": UUID("cfe82c6f-7d8c-4172-bc33-04a24a065c20"), + "email": "example@email.com", + "expires": 123, + "token_type": "refresh", + "role": "USER", + } +) +fake_user_orm = UserORM( + id=UUID("e79229a7-b91f-475a-a88d-4f3ed6e29da4"), + email="example@email.com", + password_hash="$2b$12$grLAzB0SQ1WSxd84K5lei.L", + role=UserRole.USER, + is_verified=True, + is_active=True, +) + + +@pytest.mark.asyncio +async def test_get_refresh_token(mocker, mock_dependencies) -> None: + mock_dependencies["token_repo"].get_refresh.return_value = refresh_orm + mock_authenticate_user = mocker.patch( + "auth_app.services.tokens.authenticate_user", + new_callable=AsyncMock, + return_value=get_user, + ) + + token_service = TokenService(**mock_dependencies) + res = await token_service.get_refresh_token(auth_data) + + mock_authenticate_user.aassert_called_once() + assert isinstance(res, RefreshTokenORM) + + +@pytest.mark.asyncio +async def test_create_refresh_token(mocker, mock_dependencies) -> None: + mock_authenticate_user = mocker.patch( + "auth_app.services.tokens.authenticate_user", + new_callable=AsyncMock, + return_value=get_user, + ) + mock_dependencies["token_repo"].get_refresh.return_value = None + mock_generate_refresh = mocker.patch( + "auth_app.services.tokens.token_handler.generate_refresh", + return_value={ + "refresh_token": "refresh_token", + "payload": { + "expires": 123, + "token_type": "refresh", + }, + }, + ) + mock_dependencies["token_repo"].create_refresh.return_value = refresh_orm + + token_service = TokenService(**mock_dependencies) + role_auth_data = RoleDataScheme( + **auth_data.model_dump(), + ) + res = await token_service.create_refresh_token(role_auth_data) + + mock_authenticate_user.aassert_called_once() + mock_generate_refresh.aassert_called_once() + assert isinstance(res, RefreshTokenORM) + + +@pytest.mark.asyncio +async def test_exchange_refresh_token(mocker, mock_dependencies) -> None: + mock_requre_expired = mocker.patch( + "auth_app.services.tokens.token_handler.requre_expired", + return_value=token_data, + ) + mock_dependencies["token_repo"].update_refresh.return_value = refresh_orm + token_service = TokenService(**mock_dependencies) + res = await token_service.exchange_refresh_token(token_data=token_data) + + mock_requre_expired.assert_called_once() + assert isinstance(res, RefreshTokenORM) + + +@pytest.mark.asyncio +async def test_create_access_token(mocker, mock_dependencies) -> None: + mock_decode_token = mocker.patch( + "auth_app.services.tokens.token_handler.decode_token", + return_value={ + "user_id": "8c024e52-38f2-4823-90e7-5817c8b4a7ab", + "email": "joe.0101@example.com", + "role": "USER", + "expires": 1750423028.1726773, + "token_type": "refresh" + }, + ) + mock_dependencies["user_repo"].get_user.return_value = fake_user_orm + token_service = TokenService(**mock_dependencies) + res = await token_service.create_access_token(token_data=token_data) + + mock_decode_token.assert_called_with(token=test_token) + assert isinstance(res, dict) From f6996a217d4cb5b7c154b3e640ef1bb1f7c340e2 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Wed, 2 Jul 2025 00:38:17 +0300 Subject: [PATCH 04/11] tests: Test refactoring --- .coverage | Bin 69632 -> 69632 bytes tests/conftest.py | 199 ++++++++++++++++++++++++++++++- tests/services/test_tokens.py | 3 +- tests/services/test_users.py | 216 ++++++++++++++++++---------------- 4 files changed, 309 insertions(+), 109 deletions(-) diff --git a/.coverage b/.coverage index 72c33d2a389e3c3424363411b4653d8a2711d32c..f920796faed7351854335391ac726c6f765c887d 100644 GIT binary patch delta 2204 zcmeHITTE0(7~VO1_UyUM%wAw`wz803tW}hPv_%UC*|J=g1?)p@tX;KSO10Wnjj_n0 zNznT|Dm_mIHZqXCo;WiFvdII*qlZgBJe>Ic zahH{}Dai!p8yUBSfWl;TSJ=1ra4Fr)jV0~*dAb;?vxe$yqI)(b+f1DK$t2Dj15C@3 zX6W-(9IQCv^Qnr)u%go?3d=cOH4vZsQR+Z}8Q^f5K!HF;3M6JLB7W~y#tR9|Ed(;z zotw|NB^u&pvP7a}s{Q5z4e^}ZCf?fi1vEu{EHm_SDk?Jqs3+7#XY6FNNal2Be3ua(m zmN&~QTrh>zsR{hD2}bhP>yL^E6qtZPO}T6Vi??a3qS|d7B5F|raOjtI0YH{i(?8bzBOeOo=CKAnMB``#-9X)_1zkW*s2+WWPSJrm zfJ)FV^cu=W&%p=q4jh6#@B(atHSj2W0~SLUOo0hOqoGqKX>~6?;n>ImA$8+=uC_Dz z1N*P%;-}^B?v^?46>?yR)YN(G)^#*s^SYL#@wvs@%^8aa{7b*3ja*y$g-q7vrrQ4d&l zNU;=cv7&yva((~;7lrKVys4rn~g)WCxc5ZKTjQ->ZqPy*gdt- zqLU1C$7blgbSWbg;IJBw83Ur2fEq&+p-TT4w>YMag_PLE} zfRoh&GQ&wKN=%$cQ4tgG58kReF?y?Orl0;QLnlTnCti7Qz3F8wFVFy!AYR^6$?@tL zo)PsFE&A!q^aUNqsT}u$praV0vv7kF$Ep--;j}7hnP(V^US?lWAQ);0RWEi;_R%N1 z|Bt?g>i&$yn|=P;jM4dR-NCtlQBmYfJ_%&#Z-XnVr&eFAZ~E)xa(=zU^+x8GOI0U0 zm^U!3W%)i$bL5X+Kk*bR*fs9RnmAoT@TVV}wXC#X^Kb==NykouN`AhI439fy3sRqfi%37G=m~xD7V=7+s-IZW@zi!0-kHmuzl~EK9kW zEhc6oxE+76IRs2J5+mRmgS#-cKNu52F-|uo1Cm8h7Qtl!8EoJ=wXK6m?GRR`;l8WmGw#jUv;oh_CwE zt94b>)nlUZZRAw`cKPsueO3EexB^ToNu?R=+)hNU(kgq4j3}HeTcsXG(tRU|^c5kX z1XI%sfBEl{NoDSznJ7+n6wBN$DFP*UMKWI?0Wnfeg4aTREleY7fdv>t4Vp=1fo#Zt zdR-%J{(1TJ1N*CMYiMyUL$EogOAiWQO^&_=R zwJ0Z*fRZP#$~AJolrP5%UrXcC`&cKIiwfV4E{dzXravq8_`M}Qfn=3#X50V=>8~sX zv1GK|Yp|E{l$mRLlZexW89N4B5l_hpBjp|$Sjkgmn;AJ`cwRA~SV3-ph4huM$U$m= znao$@g6Pw8&xJV^LcBzg>^=FwMDwy}c7Vwct%!~@5*Lx=-3&oM4Q8NcQiMW!DN6F5eDdur@kbAv9S-*dsF6$^j62{uStaBVC6d z;Ggj{zKMtM=lElM2A{&maRWYxYjFjB8RuaQZ^iLw4b7t&G=Z+8LF7k0s2jDT_s}uq zLx<3tr~+w7LY#U}y`f%K&r|W7P!FoLYPsrF)BRDVF@4#%$PYh)= z2Ns$-rfz2pP5#umh19ot9rlgvcw=w!#QjI#Wi>w>f4KH-{gn^aF933@^;+|^;GpNx z4(YNlCYG)eV&Cal`7BPSb38fKIlQ=hr48CCjO?6XV;606?EqRCV&zZ_wQ(`lqGe65 z<>(K`Tj*Z1B>pyjr?Fw?&fxDO^ydpS%=i{|+#fx+O>Z;P3rXfgA#-xDX0qexk(%bo zn#nUY9|9Q7LPm2iirJ_aWfQe_lVnUA(~J-^5OWfu){2(0Ggl2zWtf^m|DQm#C{{Qw zLzzLcRhFo=OT(8gWd+)x$Uu>k1Zu59b+y|Lppjul&IEZ65I4ZHfrWstX%?={>TL#k zlw`QI+~QyVN(6&>*RKh&yLJytxqoN{SVrrGz~Qh z$so)43mAG_V#ZSAxxdQ{HW_6&92OgVfyvFEKenXjMMc-ilyiFFX|~P{sYvvHf4wt! z`cgz!1G`OOJ?b4|4{4bw3@Z{{)OAPh1rrcXjSiH~*+gf8JCwHrO6} ooCfE=q)!ohYN(v`RLpF diff --git a/tests/conftest.py b/tests/conftest.py index 75c99a2..3ef768f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,28 @@ +from datetime import ( + datetime, + timedelta, +) from unittest.mock import ( AsyncMock, + MagicMock, Mock, + patch, ) +from uuid import uuid4 import pytest +from redis.asyncio.client import Redis +from auth_app.models import UserORM +from auth_app.models.users import UserRole +from auth_app.schemes.email import EmailPayloadScheme +from auth_app.schemes.users import ( + CreateUserExtendedScheme, + RoleEnum, +) +from auth_app.services.ses.ses_handler import ses_handler from auth_app.services.users import UserService +from auth_app.services.utils.pwd_hashing import hash_password @pytest.fixture @@ -65,10 +82,182 @@ def mock_dependencies(): @pytest.fixture -def user_service(mock_dependencies): +def mock_user_repo(): + return Mock( + create_user=AsyncMock(), + get_user=AsyncMock(), + update_user=AsyncMock(), + get_users=AsyncMock(), + ) + + +@pytest.fixture +def mock_token_repo(): + return Mock( + create_refresh=AsyncMock(), + get_refresh=AsyncMock(), + update_refresh=AsyncMock(), + ) + + +@pytest.fixture +def verification_code() -> str: + return "123Code!" + + +@pytest.fixture +def mock_redis( + verification_code +) -> Redis: + mock = MagicMock() + mock.set = AsyncMock(return_value=None) + mock.get = AsyncMock(return_value=verification_code) + return mock + + +@pytest.fixture +def mock_ses(): + return Mock() + + +@pytest.fixture +def mock_user_service( + mock_user_repo, + mock_token_repo, + mock_redis, + mock_ses +) -> UserService: return UserService( - user_repo=mock_dependencies["user_repo"], - token_repo=mock_dependencies["token_repo"], - redis=mock_dependencies["redis"], - ses=mock_dependencies["ses"], + user_repo=mock_user_repo, + token_repo=mock_token_repo, + redis=mock_redis, + ses=mock_ses, + ) + + +@pytest.fixture(scope="session") +def get_user_data() -> dict: + email = "email@example.com" + password = "12345Password!" + hashed = hash_password(password) + return { + "email": email, + "password": password, + "password_hash": hashed, + } + + +@pytest.fixture(scope="session") +def user_id(): + return uuid4() + + +@pytest.fixture +def get_user_orm( + get_user_data, + user_id +) -> UserORM: + return UserORM( + id=user_id, + email=get_user_data["email"], + password_hash=get_user_data["password_hash"], + role=UserRole.USER, + is_verified=False, + is_active=True, + ) + + +@pytest.fixture +def get_verified_user_orm( + get_user_data, + user_id +) -> UserORM: + return UserORM( + id=user_id, + email=get_user_data["email"], + password_hash=get_user_data["password_hash"], + role=UserRole.USER, + is_verified=True, + is_active=True, + ) + + +@pytest.fixture(scope="session") +def get_token_life_time() -> dict: + active_time = datetime.utcnow() + timedelta(minutes=30) + inactive_time = datetime.utcnow() - timedelta(minutes=30) + return { + "active_time": active_time, + "inactive_time": inactive_time, + } + + +@pytest.fixture +def valid_user_creation_data( + get_user_data, +) -> CreateUserExtendedScheme: + return CreateUserExtendedScheme( + email=get_user_data["email"], + password_hash=get_user_data["password_hash"], + role=RoleEnum.USER, + ) + + +@pytest.fixture +def invalid_admin_creation_data( + get_user_data, +) -> CreateUserExtendedScheme: + return CreateUserExtendedScheme( + email=get_user_data["email"], + password_hash=get_user_data["password_hash"], + role=RoleEnum.ADMIN, + admin_code=None, + ) + + +@pytest.fixture +def refresh_user_payload( + get_token_life_time, + get_user_data, + user_id, +) -> dict: + return { + "user_id": str(user_id), + "email": get_user_data["email"], + "role": UserRole.USER, + "expires": get_token_life_time["active_time"], + "token_type": "refresh", + } + + +@pytest.fixture +def mock_ses_handler( + verification_code +): + with patch.object(ses_handler, "generate_email_payload") as mock_gen_payload, \ + patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ + patch.object(ses_handler, "generate_otp") as mock_generate_otp: + # patch.object(ses_handler, "reset_password", new_callable=AsyncMock) as mock_reset_pwd: + mock_send_email.return_value = None + # mock_reset_pwd.return_value = + mock_generate_otp.return_value = verification_code + mock_gen_payload.return_value = EmailPayloadScheme( + message="Test message", + subject="Test subject", + source="sender@example.com", + ) + + yield { + "mock_send_email": mock_send_email, + "mock_generate_otp": mock_generate_otp, + "mock_gen_payload": mock_gen_payload, + } + + +@pytest.fixture +def mock_message_creator(mocker): + mock_msg = mocker.patch( + "auth_app.services.users.msg_creator.get_code_message", + return_value="Code sent", ) + return mock_msg diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index 3ba72b4..6e17486 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime from unittest.mock import AsyncMock from uuid import UUID @@ -16,7 +16,6 @@ RoleEnum, ) from auth_app.services.tokens import TokenService -from auth_app.services.users import UserService from auth_app.services.utils.token_handler import TokenData auth_data = AuthUserScheme( diff --git a/tests/services/test_users.py b/tests/services/test_users.py index d817095..fae7ae8 100644 --- a/tests/services/test_users.py +++ b/tests/services/test_users.py @@ -1,149 +1,161 @@ from unittest.mock import AsyncMock -from uuid import UUID import pytest -from auth_app.exeptions.custom import ServiceError +from auth_app.exeptions.custom import ( + ServiceError, + UserVerificationError, +) from auth_app.models import UserORM -from auth_app.models.users import UserRole from auth_app.schemes.users import ( CreateResponseScheme, - CreateUserExtendedScheme, + GetUserScheme, MessageResponseScheme, RoleEnum, ) -from auth_app.services.users import UserService - -fake_user_orm = UserORM( - id=UUID("e79229a7-b91f-475a-a88d-4f3ed6e29da4"), - email="example@email.com", - password_hash="$2b$12$grLAzB0SQ1WSxd84K5lei.L", - role=UserRole.USER, - is_verified=False, - is_active=True, -) - -payload = { - "email": "example@email.com", - "user_id": "e79229a7-b91f-475a-a88d-4f3ed6e29da4", -} @pytest.mark.asyncio -async def test_create_user_record_base(user_service, mock_dependencies) -> None: - user_data = CreateUserExtendedScheme( - email="example@email.com", - password_hash="1234PwdExample!", - role=RoleEnum.USER, - ) - mock_dependencies["user_repo"].create_user.return_value = fake_user_orm - user_orm = await user_service.create_user_record( - user_data=user_data, +async def test_create_user_record_base( + mock_user_service, + get_user_orm, + valid_user_creation_data, +) -> None: + """ + Test user creation with role USER and valid data + """ + mock_user_service.user_repo.create_user.return_value = get_user_orm + user_orm = await mock_user_service.create_user_record( + user_data=valid_user_creation_data, ) assert isinstance(user_orm, UserORM) assert user_orm.role == RoleEnum.USER - assert user_orm.email == "example@email.com" + assert user_orm.email == "email@example.com" @pytest.mark.asyncio -async def test_create_user_record_admin(mock_dependencies) -> None: - admin_data = CreateUserExtendedScheme( - email="example@email.com", - password_hash="1234PwdExample!", - role=RoleEnum.ADMIN, - admin_code=None, - ) - user_service = UserService(**mock_dependencies) +async def test_create_user_record_admin( + mock_user_service, + invalid_admin_creation_data, +) -> None: + """ + Test user creation with an invalid admin code for the ADMIN role + """ with pytest.raises(ServiceError) as exc_info: - await user_service.create_user_record(user_data=admin_data) + await mock_user_service.create_user_record( + user_data=invalid_admin_creation_data, + ) assert "Invalid role or permission code" in str(exc_info.value) @pytest.mark.asyncio -async def test_create_init_code_message(mocker, mock_dependencies): - user_data = fake_user_orm - mock_send = mocker.patch( - "auth_app.services.users.ses_handler.send_confirmation_email", - new_callable=AsyncMock, - ) - mock_msg = mocker.patch( - "auth_app.services.users.msg_creator.get_code_message", - return_value="Code sent", - ) - user_service = UserService(**mock_dependencies) - response = await user_service.create_init_code_message(user_data) - mock_send.assert_called_once_with( - email_to="example@email.com", - ses=mock_dependencies["ses"], - redis_client=mock_dependencies["redis"], - ) - mock_msg.assert_called_once_with("example@email.com") +async def test_create_init_code_message( + get_user_orm, + mock_user_service, + mock_ses_handler, + mock_message_creator, +) -> None: + """ + Test message creation with initialization code and response structure + """ + response = await mock_user_service.create_init_code_message(get_user_orm) + mock_ses_handler["mock_send_email"].assert_awaited_once() + mock_message_creator.assert_called_once_with(get_user_orm.email) assert isinstance(response, CreateResponseScheme) - assert response.record.email == "example@email.com" + assert response.record.email == get_user_orm.email assert response.message == "Code sent" + assert isinstance(response.record, GetUserScheme) @pytest.mark.asyncio -async def test_create_verification_code(mocker, mock_dependencies): - mock_send_email = mocker.patch( - "auth_app.services.ses.ses_handler.SesHandler.send_confirmation_email", - new_callable=AsyncMock, - return_value=None, - ) - mock_get_code_message = mocker.patch( - "auth_app.messages.common.MessageCreator.get_code_message", - return_value="Code sent", +async def test_create_verification_code( + mock_user_service, + refresh_user_payload, + mock_ses_handler, + mock_message_creator, +) -> None: + """ + Test verification code creation and response structure + """ + response = await mock_user_service.create_verification_code( + payload=refresh_user_payload, ) - user_service = UserService(**mock_dependencies) - response = await user_service.create_verification_code( - payload=payload, - ) - mock_send_email.assert_called_once_with( - email_to="example@email.com", - ses=mock_dependencies["ses"], - redis_client=mock_dependencies["redis"], - ) - mock_get_code_message.assert_called_once_with("example@email.com") + mock_ses_handler["mock_send_email"].assert_called_once() + mock_message_creator.assert_called_once_with(refresh_user_payload["email"]) assert isinstance(response, MessageResponseScheme) assert response.message == "Code sent" @pytest.mark.asyncio -async def test_execute_verification(mocker, mock_dependencies) -> None: - mock_verify_auth_code = mocker.patch( - "auth_app.services.users.verify_auth_code", - new_callable=AsyncMock, - return_value=True, +async def test_execute_verification_success( + mocker, + mock_user_service, + get_verified_user_orm, + refresh_user_payload, + verification_code, + mock_redis, +) -> None: + """ + Test successful execution of the verification + """ + mocker.patch( + "auth_app.services.utils.verification.redis_client", + new=mock_redis, ) - mock_dependencies["user_repo"].update_user.return_value = fake_user_orm + mock_user_service.verify_auth_code = AsyncMock(return_value=True) + mock_user_service.user_repo.update_user.return_value = get_verified_user_orm - user_service = UserService(**mock_dependencies) - user_data = await user_service.execute_verification( - verification_code="123", - payload=payload, - ) - mock_verify_auth_code.aassert_called_once_with( - email="example@email.com", - code="123", + user_data = await mock_user_service.execute_verification( + verification_code=verification_code, + payload=refresh_user_payload, ) assert isinstance(user_data, UserORM) + assert user_data.is_verified @pytest.mark.asyncio -async def test_reset_password(mocker, mock_dependencies) -> None: +async def test_execute_verification_failure( + mocker, + mock_user_service, + get_verified_user_orm, + refresh_user_payload, + verification_code, + mock_redis, +) -> None: + """ + Test unsuccessful execution of the verification + """ mocker.patch( - "auth_app.services.ses.ses_handler.SesHandler.reset_password", - new_callable=AsyncMock, - return_value={ - 'message': "message", - 'new_password': "password", - }, + "auth_app.services.utils.verification.redis_client", + new=mock_redis, ) - mock_dependencies["user_repo"].update_user.return_value = fake_user_orm - user_service = UserService(**mock_dependencies) - response = await user_service.reset_password( - payload=payload, + mock_redis.get.return_value = "WrongCode" + with pytest.raises(UserVerificationError) as exc_info: + mock_user_service.verify_auth_code = AsyncMock(return_value=False) + mock_user_service.user_repo.update_user.return_value = get_verified_user_orm + await mock_user_service.execute_verification( + verification_code=verification_code, + payload=refresh_user_payload, + ) + + assert "The user is not verified or verification failed" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_reset_password( + mocker, + mock_user_service, + refresh_user_payload, + get_user_orm, + mock_ses_handler, +) -> None: + """ + Test successful execution of the password reset + """ + mock_user_service.user_repo.update_user.return_value = get_user_orm + response = await mock_user_service.reset_password( + payload=refresh_user_payload, ) assert isinstance(response, dict) - assert response["message"] == "message" + assert response["message"] == "Password was changed. Check your email to get it." From 4b2a7be2193d8a23930a5c2cc5593b2da82b7f74 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Thu, 3 Jul 2025 01:53:19 +0300 Subject: [PATCH 05/11] tests: Refactoring of token_service tests --- .coverage | Bin 69632 -> 69632 bytes auth_app/services/tokens.py | 8 +- auth_app/services/users.py | 35 ++- auth_app/services/utils/verification.py | 27 --- tests/conftest.py | 125 ++++++++--- tests/services/test_tokens.py | 270 ++++++++++++++---------- tests/services/test_users.py | 83 +++++--- 7 files changed, 337 insertions(+), 211 deletions(-) delete mode 100644 auth_app/services/utils/verification.py diff --git a/.coverage b/.coverage index f920796faed7351854335391ac726c6f765c887d..84d8c93cdd2ba927c84bc78a34cc7ff08baa0f44 100644 GIT binary patch delta 4612 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 l{b*W>^I delta 4801 zcmeHLdvKM-76118zWtu}+s*rN19|bzi+fFoBtYJ;OF~G9Rk1*Lg%~BFm{5i?$tBZH zEs6tSr;S)7X{U9{fI&>BN)^!7!Z@RbjP7Vz>Pj_Ac}v-;6yTe<7dPTra=+Xu=SqXpKB-#5;$iWA zQA8)u_fd{;P3Y<9$QFw-J2z95&NA~u&^)%JVv+UWnx^#|>mF%N`_8t)4Ax$m&K@c< zjrD7R&q|ksZLGsHS8rU?&;){lyI5{%5<8ctvyy@!?3RW0RJQqImAK5so-L?Ba~UeU zAEk9x5tPKv6jHXQKw(ENnMz42Obuq$m$S*1X8&eYDIl5wtLLy*4@JrB2~Q1Zo%h@; z7SCao#r|zx@#tpDribgAxP7Iw*_Falv1k_efo~ADw^&6pCppUAxNM5WGeEfnPq#Nb zU!28O4(ej@G!QN_>cEeU+|fjElrNyNbAusDSprNBXGaTCSksWkQig)W!uW|93W!3* z;yBQEv5}#0u^<*=P@t4dwKss^K)k}x6y`0;SE^$`IE(uf{@uDq8a6bnThr38iAS!= z2_t@Qw=bqR8n`&ycOIRxC}6|c=JJKcWORxm{pZxRBv>qsVEq;8th?OO*RE-t5Q3D#lfSGZW~(w$9*1^7{V80D&$&`C4FcuyD=j-VfyH_W3{B;O;)Nv5$& zzhUI-4_a5OgZlHZP`&_IPO5yJho$11_zXG11ck1 z+Q#x%xm7=$(pk=Z{zXa(D8B1kGN9>$noK}V40hMZ?Qoa_z0T9}pIQG5Jnt zp)_`MC6`RWsns%EvGXgk*j7&%PO%r@-0PkQfY-U@_W@>34EZ+%Tp0me&0)heWvt`! z0_H2kdy5q`3%twbXhJhV!_BG&Eq(H4&Y0MbLq}!ZgO~#hO%KeQ#Mm@&o!ezY3@6$V z${(7?(S;Hw#%yq*c+f3DaS)6`6bq~KfQ6!|tnHoxJY|^-fC4X^TUr`6Hm~>$tYQK# zZ&@6Mos}}6io1O9QZSAV912Iyr1?>Sg9B)xNcR4U42~?v1hOI)%k7c&Np_b<;P46= z0LIUiPpPeKXliJwtp#9(*=E9FlK|F21KaJW2uC4-Z2)xI1n7b%!f#`a1TYs=Cbx(3 zYXMLUmFYsM)!q_NhX{LaWGS{L%*X=hQRD!d%aCc`EWla7Gh-RS>ltgm^|-agT5eTaMOKED*c%-5qQe{HmXV(5J{$D$fq{Ygv8-H`xIhx? z`t2rf|FL5OZ%XD|;G^dL`S)2L`XnU{7?LJBQ-NltCMmmKOy3;+_X7#n9j^^p5fAS$Tx)Jrer_U%?EpH9Uf0yL34)i=_&y=_3b zH6VpsAW5-)`1H*~v4Y^7>gzf+dgH`y#laOEqGEDtig5Ww9$?D7j7;*fsw51L^J>#ZgNPV%6D+!6h5rKLf`I`IlYLi;6?*D9kQrc8gIW<|OluTZy@a6xuHt|9j_;nme3nZfd zU7^xiq=7G0sI(S&DWsUsDO5OFJ1unCV^bG9agrV_lydG16)OxyvK%df44BE3w}a6_ zo~=6}b!we)6q#rNTm5zs%C;kO=4~g=vXeef!13>dB^>YSUzqYoyuDRLY#ocYE z{X43Y`LJ+}oA+<36Ab^%_QkGF!1=xYx~mfoakL2~wqdJBH1vhb5+-z~P-W};dqMKG zl_=k%^PO6QK#$R8I-R@$joxhQ9jo0cu|&s94zDBIylNgY*PBy}KN-6Xk0IzU>KpY? z?WEQKRVG!Fzhu*eQoq*TNseD2Z3if(vSU8SCv6mt&6c)92LKP6ZNsMgA9|=e8%3P_ z>5eMEK}(5JcoT_}?(DKqbRfTcPNSnU=rg^a^qCeUrc9wnps&#%LA&skyn2O>y;^AWbCilOK~M1VL6Cw8X`W_ None: self.__user_repo = user_repo self.__token_repo = token_repo - self.__redis = redis - self.__ses = ses + self.__redis = redis # pylint: disable=W0238 + self.__ses = ses # pylint: disable=W0238 @property def user_repo(self) -> UserRepo: @@ -53,12 +53,12 @@ async def get_refresh_token( user_repo=self.__user_repo, ) if not user: - raise ServiceError('User not found or Invalid user data') + raise ServiceError("User not found or Invalid user data") result = await self.__token_repo.get_refresh( user_id=user.id, ) if not result: - raise ServiceError('Token not found') + raise ServiceError("Token not found") return result async def create_refresh_token( diff --git a/auth_app/services/users.py b/auth_app/services/users.py index 842aeb0..5b3f266 100644 --- a/auth_app/services/users.py +++ b/auth_app/services/users.py @@ -6,6 +6,7 @@ from auth_app.config import jwt_settings from auth_app.exeptions.custom import ( ServiceError, + UserActivityError, UserVerificationError, ) from auth_app.messages.common import msg_creator @@ -22,7 +23,6 @@ ) from auth_app.services.ses.ses_handler import ses_handler from auth_app.services.utils.pwd_hashing import hash_password -from auth_app.services.utils.verification import verify_auth_code class UserService: @@ -34,7 +34,7 @@ def __init__( ses: AioBaseClient, ) -> None: self.__user_repo = user_repo - self.__token_repo = token_repo + self.__token_repo = token_repo # pylint: disable=W0238 self.__redis = redis self.__ses = ses @@ -42,6 +42,35 @@ def __init__( def user_repo(self) -> UserRepo: return self.__user_repo + @property + def redis(self) -> Redis: + return self.__redis + + @property + def ses(self) -> AioBaseClient: + return self.__ses + + async def verify_auth_code( + self, + email: str, + code: str, + ) -> bool: + """ + Compare the transmitted one-time password with the cached one + """ + cached_code = await self.__redis.get(f"otp:{email}") + print(f"cached_code: {cached_code}, otp: {code}") + return cached_code == code + + async def check_auth_statuses( + self, + user_data: GetUserScheme, + ) -> None: + if not user_data.is_verified: + raise UserVerificationError() + if not user_data.is_active: + raise UserActivityError + async def create_user_record( self, user_data: CreateUserExtendedScheme, @@ -92,7 +121,7 @@ async def execute_verification( payload: dict, ) -> UserORM: email_to = payload["email"] - check = await verify_auth_code( + check = await self.verify_auth_code( email=email_to, code=verification_code, ) diff --git a/auth_app/services/utils/verification.py b/auth_app/services/utils/verification.py deleted file mode 100644 index 10a4097..0000000 --- a/auth_app/services/utils/verification.py +++ /dev/null @@ -1,27 +0,0 @@ -from auth_app.db.connect_redis import redis_client -from auth_app.exeptions.custom import ( - UserActivityError, - UserVerificationError, -) -from auth_app.schemes.users import GetUserScheme - - -async def verify_auth_code( - email: str, - code: str, -) -> bool: - """ - Compare the transmitted one-time password with the cached one - """ - cached_code = await redis_client.get(f"otp:{email}") - print(f"cached_code: {cached_code}, otp: {code}") - return cached_code == code - - -async def check_auth_statuses( - user_data: GetUserScheme, -) -> None: - if not user_data.is_verified: - raise UserVerificationError() - if not user_data.is_active: - raise UserActivityError diff --git a/tests/conftest.py b/tests/conftest.py index 3ef768f..3856df6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,16 +13,19 @@ import pytest from redis.asyncio.client import Redis -from auth_app.models import UserORM +from auth_app.config import jwt_settings +from auth_app.models import RefreshTokenORM, UserORM from auth_app.models.users import UserRole -from auth_app.schemes.email import EmailPayloadScheme +from auth_app.schemes.tokens import CreateDataScheme from auth_app.schemes.users import ( CreateUserExtendedScheme, + GetUserScheme, RoleEnum, ) -from auth_app.services.ses.ses_handler import ses_handler +from auth_app.services.tokens import TokenService from auth_app.services.users import UserService from auth_app.services.utils.pwd_hashing import hash_password +from auth_app.services.utils.token_handler import token_handler @pytest.fixture @@ -77,7 +80,9 @@ def mock_dependencies(): update_refresh=AsyncMock(), ), "redis": Mock(), - "ses": Mock(), + "ses": Mock( + + ), } @@ -135,6 +140,21 @@ def mock_user_service( ) +@pytest.fixture +def mock_token_service( + mock_user_repo, + mock_token_repo, + mock_redis, + mock_ses +) -> TokenService: + return TokenService( + user_repo=mock_user_repo, + token_repo=mock_token_repo, + redis=mock_redis, + ses=mock_ses, + ) + + @pytest.fixture(scope="session") def get_user_data() -> dict: email = "email@example.com" @@ -152,8 +172,8 @@ def user_id(): return uuid4() -@pytest.fixture -def get_user_orm( +@pytest.fixture(scope="session") +def user_orm_mock( get_user_data, user_id ) -> UserORM: @@ -168,7 +188,7 @@ def get_user_orm( @pytest.fixture -def get_verified_user_orm( +def verified_user_orm_mock( get_user_data, user_id ) -> UserORM: @@ -183,7 +203,7 @@ def get_verified_user_orm( @pytest.fixture(scope="session") -def get_token_life_time() -> dict: +def token_life_time_mock() -> dict: active_time = datetime.utcnow() + timedelta(minutes=30) inactive_time = datetime.utcnow() - timedelta(minutes=30) return { @@ -217,7 +237,7 @@ def invalid_admin_creation_data( @pytest.fixture def refresh_user_payload( - get_token_life_time, + token_life_time_mock, get_user_data, user_id, ) -> dict: @@ -225,39 +245,74 @@ def refresh_user_payload( "user_id": str(user_id), "email": get_user_data["email"], "role": UserRole.USER, - "expires": get_token_life_time["active_time"], + "expires": token_life_time_mock["active_time"], "token_type": "refresh", } @pytest.fixture -def mock_ses_handler( - verification_code -): - with patch.object(ses_handler, "generate_email_payload") as mock_gen_payload, \ - patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ - patch.object(ses_handler, "generate_otp") as mock_generate_otp: - # patch.object(ses_handler, "reset_password", new_callable=AsyncMock) as mock_reset_pwd: - mock_send_email.return_value = None - # mock_reset_pwd.return_value = - mock_generate_otp.return_value = verification_code - mock_gen_payload.return_value = EmailPayloadScheme( - message="Test message", - subject="Test subject", - source="sender@example.com", - ) - - yield { - "mock_send_email": mock_send_email, - "mock_generate_otp": mock_generate_otp, - "mock_gen_payload": mock_gen_payload, - } - - -@pytest.fixture -def mock_message_creator(mocker): +def mock_message_creator( + mocker, +) -> MagicMock: mock_msg = mocker.patch( "auth_app.services.users.msg_creator.get_code_message", return_value="Code sent", ) return mock_msg + + +@pytest.fixture(scope="session") +def refresh_tokens_mock( + user_orm_mock, +) -> dict: + user_refresh_mock = token_handler.generate_refresh( + CreateDataScheme( + user_id=user_orm_mock.id, + email=user_orm_mock.email, + role=RoleEnum.USER, + admin_secret=None, + ) + ) + admin_refresh_mock = token_handler.generate_refresh( + CreateDataScheme( + user_id=user_orm_mock.id, + email=user_orm_mock.email, + role=RoleEnum.ADMIN, + admin_secret=jwt_settings.ADMIN_SECRET.get_secret_value(), + ) + ) + return { + "user_refresh_mock": user_refresh_mock["refresh_token"], + "user_payload_mock": user_refresh_mock["payload"], + "admin_refresh_mock": admin_refresh_mock["refresh_token"], + "admin_payload_mock": admin_refresh_mock["payload"], + } + + +@pytest.fixture(scope="session") +def refresh_orm_mock( + refresh_tokens_mock, +) -> RefreshTokenORM: + payload = refresh_tokens_mock["user_payload_mock"] + + refresh_orm = RefreshTokenORM( + id=uuid4(), + user_id=payload["user_id"], + token=refresh_tokens_mock["user_refresh_mock"], + expires_at=payload["expires"], + ) + return refresh_orm + + +@pytest.fixture(scope="session") +def get_user_scheme_mock( + user_orm_mock, +) -> GetUserScheme: + return GetUserScheme( + email=user_orm_mock.email, + password_hash=user_orm_mock.password_hash, + id=user_orm_mock.id, + role=user_orm_mock.role, + is_verified=user_orm_mock.is_verified, + is_active=user_orm_mock.is_active, + ) diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index 6e17486..27fafe8 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -1,143 +1,199 @@ -from datetime import datetime -from unittest.mock import AsyncMock -from uuid import UUID +from unittest.mock import ( + AsyncMock, + MagicMock, +) import pytest +from auth_app.exeptions.custom import ServiceError from auth_app.models import RefreshTokenORM -from auth_app.models.users import ( - UserORM, - UserRole, -) from auth_app.schemes.tokens import RoleDataScheme from auth_app.schemes.users import ( AuthUserScheme, - GetUserScheme, - RoleEnum, ) -from auth_app.services.tokens import TokenService from auth_app.services.utils.token_handler import TokenData -auth_data = AuthUserScheme( - **{ - "email": "example@email.com", - "password_hash": "examplePassword123!", - } -) -refresh_orm = RefreshTokenORM( - id=UUID("cfe82c6f-7d8c-4172-bc33-04a24a065c20"), - user_id=UUID("8c024e52-38f2-4823-90e7-5817c8b4a7ab"), - token="eyJhbGciOiJIUzI1NiIkpXVCJ9.eyJ1cmcmVzaCJ9.mvbiRt8fK2yiQXER-PiYdk465IdDjg0cry4", - expires_at=datetime.now(), -) -get_user = GetUserScheme( - **{ - "email": "example@email.com", - "password_hash": "$2b$12$WvfCqCmMM2xN4O4d8uVTnOANVCQXg0rwtYxiTi2KT8NxYLcpwNC/O", - "id": UUID("8c024e52-38f2-4823-90e7-5817c8b4a7ab"), - "role": RoleEnum.USER, - "is_verified": False, - "is_active": True, - } -) -test_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiOGMwMjRlNTItMzhmMi00ODIzLTkwZTctNTgxN2M4YjRhN2FiIiwiZW1haWwiOiJqb2UuMDEwMUBleGFtcGxlLmNvbSIsInJvbGUiOiJVU0VSIiwiZXhwaXJlcyI6MTc1MDQyMzAyOC4xNzI2NzczLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.mvbiRt8fK2yiQXER-PiYdk465IdDjg0NRxcKRLXcry4" - -token_data = TokenData( - token=test_token, - payload={ - "user_id": UUID("cfe82c6f-7d8c-4172-bc33-04a24a065c20"), - "email": "example@email.com", - "expires": 123, - "token_type": "refresh", - "role": "USER", - } -) -fake_user_orm = UserORM( - id=UUID("e79229a7-b91f-475a-a88d-4f3ed6e29da4"), - email="example@email.com", - password_hash="$2b$12$grLAzB0SQ1WSxd84K5lei.L", - role=UserRole.USER, - is_verified=True, - is_active=True, -) +@pytest.fixture +def auth_user_data_mock( + user_orm_mock, +) -> AuthUserScheme: + return AuthUserScheme( + email=user_orm_mock.email, + password_hash=user_orm_mock.password_hash, + ) -@pytest.mark.asyncio -async def test_get_refresh_token(mocker, mock_dependencies) -> None: - mock_dependencies["token_repo"].get_refresh.return_value = refresh_orm - mock_authenticate_user = mocker.patch( - "auth_app.services.tokens.authenticate_user", - new_callable=AsyncMock, - return_value=get_user, + +@pytest.fixture +def generate_refresh_patch( + mocker, + refresh_tokens_mock, +) -> MagicMock: + refresh_mock = mocker.patch( + "auth_app.services.tokens.token_handler.generate_refresh", + return_value={ + "refresh_token": refresh_tokens_mock["user_refresh_mock"], + "payload": refresh_tokens_mock["user_payload_mock"], + } ) + return refresh_mock - token_service = TokenService(**mock_dependencies) - res = await token_service.get_refresh_token(auth_data) - mock_authenticate_user.aassert_called_once() - assert isinstance(res, RefreshTokenORM) +@pytest.fixture +def token_data( + refresh_tokens_mock, +) -> TokenData: + token_data = TokenData( + token=refresh_tokens_mock["user_refresh_mock"], + payload=refresh_tokens_mock["user_payload_mock"] + ) + return token_data -@pytest.mark.asyncio -async def test_create_refresh_token(mocker, mock_dependencies) -> None: - mock_authenticate_user = mocker.patch( +@pytest.fixture +def requre_expired_patch( + mocker, + token_data, +) -> MagicMock: + requre_expired = mocker.patch( + "auth_app.services.tokens.token_handler.requre_expired", + return_value=token_data, + ) + return requre_expired + + +@pytest.fixture +def authenticate_user_patch( + mocker, + get_user_scheme_mock +) -> AsyncMock: + auth_mock = mocker.patch( "auth_app.services.tokens.authenticate_user", new_callable=AsyncMock, - return_value=get_user, - ) - mock_dependencies["token_repo"].get_refresh.return_value = None - mock_generate_refresh = mocker.patch( - "auth_app.services.tokens.token_handler.generate_refresh", - return_value={ - "refresh_token": "refresh_token", - "payload": { - "expires": 123, - "token_type": "refresh", - }, - }, + return_value=get_user_scheme_mock, ) - mock_dependencies["token_repo"].create_refresh.return_value = refresh_orm + return auth_mock - token_service = TokenService(**mock_dependencies) - role_auth_data = RoleDataScheme( - **auth_data.model_dump(), + +@pytest.fixture +def authenticate_user_failure_patch( + mocker, +) -> AsyncMock: + auth_mock = mocker.patch( + "auth_app.services.tokens.authenticate_user", + new_callable=AsyncMock, + return_value=None, ) - res = await token_service.create_refresh_token(role_auth_data) + return auth_mock + - mock_authenticate_user.aassert_called_once() - mock_generate_refresh.aassert_called_once() +@pytest.mark.asyncio +async def test_get_refresh_token( + auth_user_data_mock, + authenticate_user_patch, + refresh_orm_mock, + mock_token_service, + +) -> None: + """ + Test refresh token retrieval and authentication call + """ + + mock_token_service.token_repo.get_refresh.return_value = refresh_orm_mock + res = await mock_token_service.get_refresh_token(auth_user_data_mock) + + authenticate_user_patch.aassert_called_once() assert isinstance(res, RefreshTokenORM) @pytest.mark.asyncio -async def test_exchange_refresh_token(mocker, mock_dependencies) -> None: - mock_requre_expired = mocker.patch( - "auth_app.services.tokens.token_handler.requre_expired", - return_value=token_data, +async def test_create_refresh_token( + auth_user_data_mock, + authenticate_user_patch, + generate_refresh_patch, + mock_token_service, + refresh_orm_mock, +) -> None: + """ + Test successful creation and validation of a refresh token + """ + + mock_token_service.token_repo.get_refresh.return_value = None + mock_token_service.token_repo.create_refresh.return_value = refresh_orm_mock + role_auth_data = RoleDataScheme( + **auth_user_data_mock.model_dump(), ) - mock_dependencies["token_repo"].update_refresh.return_value = refresh_orm - token_service = TokenService(**mock_dependencies) - res = await token_service.exchange_refresh_token(token_data=token_data) + res = await mock_token_service.create_refresh_token(role_auth_data) - mock_requre_expired.assert_called_once() + authenticate_user_patch.aassert_called_once() + generate_refresh_patch.aassert_called_once() assert isinstance(res, RefreshTokenORM) @pytest.mark.asyncio -async def test_create_access_token(mocker, mock_dependencies) -> None: - mock_decode_token = mocker.patch( - "auth_app.services.tokens.token_handler.decode_token", - return_value={ - "user_id": "8c024e52-38f2-4823-90e7-5817c8b4a7ab", - "email": "joe.0101@example.com", - "role": "USER", - "expires": 1750423028.1726773, - "token_type": "refresh" - }, +async def test_create_refresh_token_failure( + auth_user_data_mock, + authenticate_user_failure_patch, + mock_token_service, +) -> None: + """ + Test unsuccessful creation of a refresh token + """ + + with pytest.raises(ServiceError) as user_not_found: + role_auth_data = RoleDataScheme( + **auth_user_data_mock.model_dump(), + ) + await mock_token_service.get_refresh_token(role_auth_data) + assert "User not found or Invalid user data" in str(user_not_found.value) + + +@pytest.mark.asyncio +async def test_exchange_refresh_token( + mock_token_service, + refresh_orm_mock, + requre_expired_patch, + token_data, +) -> None: + """ + Test successful exchange of a refresh token + """ + mock_token_service.token_repo.update_refresh.return_value = refresh_orm_mock + res = await mock_token_service.exchange_refresh_token( + token_data=token_data, ) - mock_dependencies["user_repo"].get_user.return_value = fake_user_orm - token_service = TokenService(**mock_dependencies) - res = await token_service.create_access_token(token_data=token_data) + requre_expired_patch.assert_called_once() + assert isinstance(res, RefreshTokenORM) - mock_decode_token.assert_called_with(token=test_token) + +@pytest.mark.asyncio +async def test_create_access_token( + mock_token_service, + verified_user_orm_mock, + token_data, +) -> None: + """ + Test successful creation of an access token + """ + mock_token_service.user_repo.get_user.return_value = verified_user_orm_mock + res = await mock_token_service.create_access_token( + token_data=token_data, + ) assert isinstance(res, dict) + + +@pytest.mark.asyncio +async def test_create_access_token_failure( + mock_token_service, + user_orm_mock, + token_data, +) -> None: + """ + Test unsuccessful creation of an access token + """ + with pytest.raises(ServiceError) as verification_error: + mock_token_service.user_repo.get_user.return_value = user_orm_mock + res = await mock_token_service.create_access_token( + token_data=token_data, + ) + assert "User must be verified" in str(verification_error.value) diff --git a/tests/services/test_users.py b/tests/services/test_users.py index fae7ae8..f5c3d97 100644 --- a/tests/services/test_users.py +++ b/tests/services/test_users.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -6,6 +6,7 @@ ServiceError, UserVerificationError, ) +from auth_app.messages.common import msg_creator from auth_app.models import UserORM from auth_app.schemes.users import ( CreateResponseScheme, @@ -15,16 +16,44 @@ ) +@pytest.fixture +def send_confirmation_email_mock( + mocker, +) -> MagicMock: + confirmation_email_mock = mocker.patch( + "auth_app.services.users.ses_handler.send_confirmation_email", + new_callable=AsyncMock, + return_value={ + "message": "message" + } + ) + return confirmation_email_mock + + +@pytest.fixture +def ses_reset_pwd_mock( + mocker, +) -> None: + mocker.patch( + "auth_app.services.users.ses_handler.reset_password", + new_callable=AsyncMock, + return_value={ + 'message': msg_creator.get_reset_pwd_message(), + 'new_password': "new_password", + } + ) + + @pytest.mark.asyncio async def test_create_user_record_base( mock_user_service, - get_user_orm, + user_orm_mock, valid_user_creation_data, ) -> None: """ Test user creation with role USER and valid data """ - mock_user_service.user_repo.create_user.return_value = get_user_orm + mock_user_service.user_repo.create_user.return_value = user_orm_mock user_orm = await mock_user_service.create_user_record( user_data=valid_user_creation_data, ) @@ -50,20 +79,20 @@ async def test_create_user_record_admin( @pytest.mark.asyncio async def test_create_init_code_message( - get_user_orm, + user_orm_mock, mock_user_service, - mock_ses_handler, + send_confirmation_email_mock, mock_message_creator, ) -> None: """ Test message creation with initialization code and response structure """ - response = await mock_user_service.create_init_code_message(get_user_orm) - mock_ses_handler["mock_send_email"].assert_awaited_once() - mock_message_creator.assert_called_once_with(get_user_orm.email) + response = await mock_user_service.create_init_code_message(user_orm_mock) + send_confirmation_email_mock.assert_awaited_once() + mock_message_creator.assert_called_once_with(user_orm_mock.email) assert isinstance(response, CreateResponseScheme) - assert response.record.email == get_user_orm.email + assert response.record.email == user_orm_mock.email assert response.message == "Code sent" assert isinstance(response.record, GetUserScheme) @@ -72,7 +101,7 @@ async def test_create_init_code_message( async def test_create_verification_code( mock_user_service, refresh_user_payload, - mock_ses_handler, + send_confirmation_email_mock, mock_message_creator, ) -> None: """ @@ -81,7 +110,7 @@ async def test_create_verification_code( response = await mock_user_service.create_verification_code( payload=refresh_user_payload, ) - mock_ses_handler["mock_send_email"].assert_called_once() + send_confirmation_email_mock.assert_called_once() mock_message_creator.assert_called_once_with(refresh_user_payload["email"]) assert isinstance(response, MessageResponseScheme) assert response.message == "Code sent" @@ -89,23 +118,15 @@ async def test_create_verification_code( @pytest.mark.asyncio async def test_execute_verification_success( - mocker, mock_user_service, - get_verified_user_orm, + verified_user_orm_mock, refresh_user_payload, verification_code, - mock_redis, ) -> None: """ Test successful execution of the verification """ - mocker.patch( - "auth_app.services.utils.verification.redis_client", - new=mock_redis, - ) - mock_user_service.verify_auth_code = AsyncMock(return_value=True) - mock_user_service.user_repo.update_user.return_value = get_verified_user_orm - + mock_user_service.user_repo.update_user.return_value = verified_user_orm_mock user_data = await mock_user_service.execute_verification( verification_code=verification_code, payload=refresh_user_payload, @@ -116,24 +137,17 @@ async def test_execute_verification_success( @pytest.mark.asyncio async def test_execute_verification_failure( - mocker, mock_user_service, - get_verified_user_orm, + verified_user_orm_mock, refresh_user_payload, verification_code, - mock_redis, ) -> None: """ Test unsuccessful execution of the verification """ - mocker.patch( - "auth_app.services.utils.verification.redis_client", - new=mock_redis, - ) - mock_redis.get.return_value = "WrongCode" + mock_user_service.redis.get.return_value = "WrongCode" with pytest.raises(UserVerificationError) as exc_info: - mock_user_service.verify_auth_code = AsyncMock(return_value=False) - mock_user_service.user_repo.update_user.return_value = get_verified_user_orm + mock_user_service.user_repo.update_user.return_value = verified_user_orm_mock await mock_user_service.execute_verification( verification_code=verification_code, payload=refresh_user_payload, @@ -144,16 +158,15 @@ async def test_execute_verification_failure( @pytest.mark.asyncio async def test_reset_password( - mocker, mock_user_service, refresh_user_payload, - get_user_orm, - mock_ses_handler, + user_orm_mock, + ses_reset_pwd_mock, ) -> None: """ Test successful execution of the password reset """ - mock_user_service.user_repo.update_user.return_value = get_user_orm + mock_user_service.user_repo.update_user.return_value = user_orm_mock response = await mock_user_service.reset_password( payload=refresh_user_payload, ) From 83923ebdb6e3d033b22b3194b74ed3ece6d527a8 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Thu, 3 Jul 2025 16:57:23 +0300 Subject: [PATCH 06/11] tests: One more part of test refactoring --- .coverage | Bin 69632 -> 69632 bytes tests/conftest.py | 91 ++++++++---- tests/general/test_messages.py | 4 + tests/services/test_ses.py | 115 +++++++++------ tests/services/test_tokens.py | 6 +- tests/services/test_users.py | 6 +- tests/services/utils/test_general_services.py | 66 ++++++--- tests/services/utils/test_jwt_handler.py | 69 +++++---- tests/services/utils/test_token_handler.py | 139 ++++++++++-------- 9 files changed, 300 insertions(+), 196 deletions(-) diff --git a/.coverage b/.coverage index 84d8c93cdd2ba927c84bc78a34cc7ff08baa0f44..de8757de0f189d0facb2f3b7cfce54d84b52f470 100644 GIT binary patch delta 1207 zcmdUvNla5g9LC?wd%V*2%}gsEj8s`%Vq4l$YCte5T~ffHR_hu8g%)uoAQ~YNTZl&E zg1{s$0g)4A+{$S5bR` zRq+!xna*8d{KZP5EXxGO8uq3#rr0R6SE$kdnyIN`00UE_cO~PGe`ow192i-%QANit z^ue1gcP(`p*P2lrM@GmU(n-#dGvqjFAbUtP@e?PRP2$M}A`t<9#h>s?+=K7ooA@$r z#z9<(H(?*n#SWZ-O?VoP#W7e!14u?~=oqR*tC1JEPzs8ZwVJC^Kx-C&a;iDC?AqI2 zUDIjrwmPgaF)7h>Bvet?NaDY~TjwWqSm4-QMBkxj7?#v0f-%V5C0i-z`U zcT6`spWAM=B)pKqG@*2|o|$v?jzvSqP7W3qyt&&c-B_n7DB0bVQ0mTeU(?QD{!}!h z=&f(f{`5z=%@6jK&`XEPRMCmd7?(IMqM-UZ-}ui!{{Hc7XyUf3kqeRKf$MF%UUtZ> zbp~L!LblKk6W7PJRNvnjv2k2vxO^!6=HdSCK=VlVx6tA0!R}$7<$E|TFcd!e>23r> z0YRz6P{nba8Fx`deLj1IJS=y$IIQk|E}rY)NDh%e|F2V}=;mkti&W(S7PM4Ze4Gr%LVa>UwA|Ul VYyT#xX;7{m?BV~5U*Jn-><^-FMgJ+A9e?Y@orbJpIJUBsW4nRugp$h72W%u0*muk!(Aec!v9pDi$(sc4jH2k9+yn z&bPJfY?$giT=_TEY`8>p55+)qF!hF>4S!#T>RikhiHc6PaB$01SoJb6Z^Shi+Kz&D z7U1)I5(I5Ja>!^F2XQ63$8^wy#d;Bm^Q=5}z$D#}-ja?<0(u7>85jt%iP#mEKK?)< zg|eB#|B%$fg2#RGUm5nYK(muapns3xW7pzMI`vV^hd z7CNB=Vo(PG@PQqyPzpx!BYB^UlGn))=_g5Ym^6_6#6`-qV)MAE)vRJsv8msf*RzX@ z9r>yn8NA#n_g?5sElf@>el8h3T$g(0KCD{1D(T%^fE#Ud@pYfe0hb=R^vOtac+__ZYIB*zskcBd~Fw zAf(p!-9CRU_tW*7FV@@sxS0#wYFi&0`aPe&^ufvworo0$kUwTN-Ks*80X;+ z0GrP06jjyp3q`M2o6d%Obw>oNFd@(eYQnvD^Hnrb)GqxXOu)xb4QY}$zGvKNJZ-ov zm2%`BCu4FSIYoA(S<@9(AG7{z5-oR;h-ZZ7?hv>04v`gcE61l&i_fXzKXatB%Q-Yt wXw!O^F~V|Ac$q6VTU}{kea#PRx%uDB|0& dict: return { "user_repo": Mock( create_user=AsyncMock(), @@ -87,8 +93,8 @@ def mock_dependencies(): @pytest.fixture -def mock_user_repo(): - return Mock( +def mock_user_repo() -> MagicMock: + return MagicMock( create_user=AsyncMock(), get_user=AsyncMock(), update_user=AsyncMock(), @@ -97,8 +103,8 @@ def mock_user_repo(): @pytest.fixture -def mock_token_repo(): - return Mock( +def mock_token_repo() -> MagicMock: + return MagicMock( create_refresh=AsyncMock(), get_refresh=AsyncMock(), update_refresh=AsyncMock(), @@ -112,17 +118,17 @@ def verification_code() -> str: @pytest.fixture def mock_redis( - verification_code -) -> Redis: - mock = MagicMock() + verification_code, +) -> MagicMock: + mock = MagicMock(spec=Redis) mock.set = AsyncMock(return_value=None) mock.get = AsyncMock(return_value=verification_code) return mock @pytest.fixture -def mock_ses(): - return Mock() +def mock_ses() -> MagicMock: + return MagicMock(spec=AioBaseClient) @pytest.fixture @@ -130,7 +136,7 @@ def mock_user_service( mock_user_repo, mock_token_repo, mock_redis, - mock_ses + mock_ses, ) -> UserService: return UserService( user_repo=mock_user_repo, @@ -145,7 +151,7 @@ def mock_token_service( mock_user_repo, mock_token_repo, mock_redis, - mock_ses + mock_ses, ) -> TokenService: return TokenService( user_repo=mock_user_repo, @@ -168,7 +174,7 @@ def get_user_data() -> dict: @pytest.fixture(scope="session") -def user_id(): +def user_id() -> UUID: return uuid4() @@ -261,25 +267,41 @@ def mock_message_creator( return mock_msg +@pytest.fixture(scope="session") +def create_user_data( + user_orm_mock: UserORM, +) -> CreateDataScheme: + return CreateDataScheme( + user_id=user_orm_mock.id, + email=user_orm_mock.email, + role=RoleEnum.USER, + admin_secret=None, + ) + + +@pytest.fixture(scope="session") +def create_admin_data( + user_orm_mock: UserORM, +) -> CreateDataScheme: + return CreateDataScheme( + user_id=user_orm_mock.id, + email=user_orm_mock.email, + role=RoleEnum.ADMIN, + admin_secret=jwt_settings.ADMIN_SECRET.get_secret_value(), + ) + + @pytest.fixture(scope="session") def refresh_tokens_mock( - user_orm_mock, + create_user_data: CreateDataScheme, + create_admin_data: CreateDataScheme, ) -> dict: user_refresh_mock = token_handler.generate_refresh( - CreateDataScheme( - user_id=user_orm_mock.id, - email=user_orm_mock.email, - role=RoleEnum.USER, - admin_secret=None, - ) + create_user_data, + ) admin_refresh_mock = token_handler.generate_refresh( - CreateDataScheme( - user_id=user_orm_mock.id, - email=user_orm_mock.email, - role=RoleEnum.ADMIN, - admin_secret=jwt_settings.ADMIN_SECRET.get_secret_value(), - ) + create_admin_data, ) return { "user_refresh_mock": user_refresh_mock["refresh_token"], @@ -289,6 +311,17 @@ def refresh_tokens_mock( } +@pytest.fixture(scope="session") +def access_token_mock( + refresh_tokens_mock, +) -> dict: + access_token = token_handler.generate_access( + refresh_token=refresh_tokens_mock["user_refresh_mock"], + extra_payload={}, + ) + return access_token + + @pytest.fixture(scope="session") def refresh_orm_mock( refresh_tokens_mock, diff --git a/tests/general/test_messages.py b/tests/general/test_messages.py index ed69d56..78abf3f 100644 --- a/tests/general/test_messages.py +++ b/tests/general/test_messages.py @@ -2,6 +2,10 @@ def test_messages() -> None: + """ + Test of expected answers of the msg_creator + """ + assert msg_creator.get_code_message("123") == f"Your verification code was sent to 123." assert msg_creator.get_reset_pwd_message() == "Password was changed. Check your email to get it." assert msg_creator.get_root_description() == "Auth REST API for the Skill Tracker Application" diff --git a/tests/services/test_ses.py b/tests/services/test_ses.py index 8a8e8da..40eb828 100644 --- a/tests/services/test_ses.py +++ b/tests/services/test_ses.py @@ -1,73 +1,94 @@ from unittest.mock import ( AsyncMock, - patch, ) import pytest from aiobotocore.client import AioBaseClient from auth_app.config import aws_settings -from auth_app.db.connect_redis import redis_client -from auth_app.messages.common import msg_creator from auth_app.schemes.email import EmailPayloadScheme from auth_app.services.ses.ses_handler import ses_handler -def test_generate_email_payload() -> None: +@pytest.fixture +def generate_email_payload_data() -> dict: + return { + "message": "Test message", + "subject": "Test subject", + } + + +@pytest.fixture +def send_email_patch( + mocker, +) -> AsyncMock: + send_email = mocker.patch( + "auth_app.services.ses.ses_handler.ses_handler.send_email", + new_callable=AsyncMock, + return_value=None, + ) + return send_email + + +def test_generate_email_payload( + generate_email_payload_data, +) -> None: + """ + Test of valid email payload creation + """ email_payload = ses_handler.generate_email_payload( - message="Test message", - subject="Test subject" + **generate_email_payload_data, ) + assert isinstance(email_payload, EmailPayloadScheme) - assert isinstance(email_payload.message, str) - assert isinstance(email_payload.subject, str) + assert email_payload.message == "Test message" + assert email_payload.subject == "Test subject" -def test_generate_otp() -> None: +def test_generate_verification_code() -> None: + """ + Test of valid verification code creation + """ code = ses_handler.generate_otp() + assert isinstance(code, str) assert len(code) == aws_settings.VERIFICATION_CODE_LENGTH @pytest.mark.asyncio -async def test_reset_password() -> None: - with patch.object(ses_handler, "generate_email_payload") as mock_gen_payload, \ - patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ - patch.object(ses_handler, "generate_otp") as mock_generate_otp: - mock_send_email.return_value = None - mock_generate_otp.return_value = "TestOTP123" - mock_gen_payload.return_value = EmailPayloadScheme( - message="Test message", - subject="Test subject", - source="sender@example.com", - ) - result = await ses_handler.reset_password( - email_to='email_to@example.com', - ses=AioBaseClient, - ) - assert isinstance(result, dict) - assert result["new_password"] == "TestOTP123" +async def test_reset_password( + send_email_patch, + get_user_data, +) -> None: + """ + Test of valid password reset + """ + result = await ses_handler.reset_password( + email_to=get_user_data["email"], + ses=AioBaseClient, + ) + + send_email_patch.assert_called_once() + assert isinstance(result, dict) + assert isinstance(result["new_password"], str) + assert len(result["new_password"]) == aws_settings.RESET_PWD_LENGTH + assert "Password was changed" in result["message"] @pytest.mark.asyncio -async def test_send_confirmation_email() -> None: - with patch.object(ses_handler, "generate_otp") as mock_generate_otp, \ - patch.object(ses_handler, "send_email", new_callable=AsyncMock) as mock_send_email, \ - patch.object(msg_creator, "get_ses_confirmation_message") as mock_get_ses_message, \ - patch.object(redis_client, "set", new_callable=AsyncMock) as mock_set_redis: - mock_generate_otp.return_value = "TestOTP123" - mock_get_ses_message.return_value = { - "message": "test message", - "subject": "test subject", - "response_message": "test response", - } - mock_send_email.return_value = None - mock_set_redis.return_value = None - result = await ses_handler.send_confirmation_email( - email_to="email_to@example.com", - ses=AioBaseClient, - redis_client=redis_client, - ) - assert isinstance(result, dict) - assert isinstance(result["message"], str) - assert result["message"] == "test response" +async def test_send_confirmation_email( + get_user_data, + mock_redis, + send_email_patch, +) -> None: + """ + Test of successful sending of the confirmation email + """ + result = await ses_handler.send_confirmation_email( + email_to=get_user_data["email"], + ses=AioBaseClient, + redis_client=mock_redis, + ) + + assert isinstance(result, dict) + assert "Verification code was sent." in result["message"] diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index 27fafe8..22fb244 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -98,7 +98,6 @@ async def test_get_refresh_token( """ Test refresh token retrieval and authentication call """ - mock_token_service.token_repo.get_refresh.return_value = refresh_orm_mock res = await mock_token_service.get_refresh_token(auth_user_data_mock) @@ -117,7 +116,6 @@ async def test_create_refresh_token( """ Test successful creation and validation of a refresh token """ - mock_token_service.token_repo.get_refresh.return_value = None mock_token_service.token_repo.create_refresh.return_value = refresh_orm_mock role_auth_data = RoleDataScheme( @@ -139,7 +137,6 @@ async def test_create_refresh_token_failure( """ Test unsuccessful creation of a refresh token """ - with pytest.raises(ServiceError) as user_not_found: role_auth_data = RoleDataScheme( **auth_user_data_mock.model_dump(), @@ -162,6 +159,7 @@ async def test_exchange_refresh_token( res = await mock_token_service.exchange_refresh_token( token_data=token_data, ) + requre_expired_patch.assert_called_once() assert isinstance(res, RefreshTokenORM) @@ -179,6 +177,7 @@ async def test_create_access_token( res = await mock_token_service.create_access_token( token_data=token_data, ) + assert isinstance(res, dict) @@ -196,4 +195,5 @@ async def test_create_access_token_failure( res = await mock_token_service.create_access_token( token_data=token_data, ) + assert "User must be verified" in str(verification_error.value) diff --git a/tests/services/test_users.py b/tests/services/test_users.py index f5c3d97..f603b5a 100644 --- a/tests/services/test_users.py +++ b/tests/services/test_users.py @@ -74,6 +74,7 @@ async def test_create_user_record_admin( await mock_user_service.create_user_record( user_data=invalid_admin_creation_data, ) + assert "Invalid role or permission code" in str(exc_info.value) @@ -88,9 +89,9 @@ async def test_create_init_code_message( Test message creation with initialization code and response structure """ response = await mock_user_service.create_init_code_message(user_orm_mock) + send_confirmation_email_mock.assert_awaited_once() mock_message_creator.assert_called_once_with(user_orm_mock.email) - assert isinstance(response, CreateResponseScheme) assert response.record.email == user_orm_mock.email assert response.message == "Code sent" @@ -110,6 +111,7 @@ async def test_create_verification_code( response = await mock_user_service.create_verification_code( payload=refresh_user_payload, ) + send_confirmation_email_mock.assert_called_once() mock_message_creator.assert_called_once_with(refresh_user_payload["email"]) assert isinstance(response, MessageResponseScheme) @@ -131,6 +133,7 @@ async def test_execute_verification_success( verification_code=verification_code, payload=refresh_user_payload, ) + assert isinstance(user_data, UserORM) assert user_data.is_verified @@ -170,5 +173,6 @@ async def test_reset_password( response = await mock_user_service.reset_password( payload=refresh_user_payload, ) + assert isinstance(response, dict) assert response["message"] == "Password was changed. Check your email to get it." diff --git a/tests/services/utils/test_general_services.py b/tests/services/utils/test_general_services.py index f6c1b6c..45d1df5 100644 --- a/tests/services/utils/test_general_services.py +++ b/tests/services/utils/test_general_services.py @@ -1,41 +1,59 @@ -from unittest.mock import AsyncMock -from uuid import UUID - import pytest -from auth_app.repositories.users import UserRepo -from auth_app.schemes.users import GetUserScheme, RoleEnum -from auth_app.services.utils.authenticate_user import authenticate_user +from auth_app.schemes.users import ( + GetUserScheme, +) +from auth_app.services.utils.authenticate_user import ( + authenticate_user, +) from auth_app.services.utils.pwd_hashing import ( hash_password, verify_password, ) -email = "email@example.com" -pwd = "12345Password!" -hashed_pwd = hash_password(pwd) +def test_hash_password( + get_user_data, +) -> None: + """ + Test that a password is hashed correctly and can be verified + """ + hashed_password = hash_password(get_user_data["password"]) -def test_hash_password() -> None: - hashed_password = hash_password(pwd) assert isinstance(hashed_password, str) + assert verify_password( + plain_password=get_user_data["password"], + hashed_password=hashed_password, + ) -def test_verify_password() -> None: - assert verify_password(pwd, hashed_pwd) +def test_verify_password( + get_user_data, +) -> None: + """ + Test successful password verification with correct password + """ + assert verify_password( + plain_password=get_user_data["password"], + hashed_password=get_user_data["password_hash"], + ) @pytest.mark.asyncio -async def test_authenticate_user() -> None: - mock_user = GetUserScheme( - email=email, - password_hash=hashed_pwd, - id=UUID("150881a3-c874-4a93-92d2-6be10a4c189c"), - role=RoleEnum.USER, - is_verified=False, - is_active=True, +async def test_authenticate_user( + get_user_data, + get_user_scheme_mock, + mock_user_repo, +) -> None: + """ + Test successful authentication with valid user data + """ + mock_user_repo.get_users.return_value = [get_user_scheme_mock] + result = await authenticate_user( + email=get_user_data["email"], + password=get_user_data["password"], + user_repo=mock_user_repo ) - mock_repo: UserRepo = AsyncMock(spec=UserRepo) - mock_repo.get_users.return_value = [mock_user] - result = await authenticate_user(email, pwd, mock_repo) + + mock_user_repo.get_users.assert_called_once() assert isinstance(result, GetUserScheme) diff --git a/tests/services/utils/test_jwt_handler.py b/tests/services/utils/test_jwt_handler.py index ee4eb07..c8be61f 100644 --- a/tests/services/utils/test_jwt_handler.py +++ b/tests/services/utils/test_jwt_handler.py @@ -1,65 +1,80 @@ -from uuid import UUID - from auth_app.schemes.tokens import CreateDataScheme -from auth_app.schemes.users import RoleEnum from auth_app.services.utils.jwt_handler import JWTHandler jwt_handler = JWTHandler() -create_data = CreateDataScheme( - user_id=UUID("123e4567-e89b-12d3-a456-426614174000"), - email="mail@example.com", - role=RoleEnum.USER, -) -extra_payload = { - "test": "test", -} -token = jwt_handler.generate_refresh(create_data=create_data)["refresh_token"] -access_token = jwt_handler.generate_access( - refresh_token=token, - extra_payload=extra_payload, -)["access_token"] -payload = jwt_handler.base_decode(token) -access_payload = jwt_handler.base_decode(access_token) -def test_get_refresh_response() -> None: +def test_get_refresh_response( + refresh_tokens_mock, +) -> None: + """ + Test that get_refresh_response returns correct response + """ + token = refresh_tokens_mock["user_refresh_mock"] + payload = refresh_tokens_mock["user_payload_mock"] response = JWTHandler.get_refresh_response( token=token, payload=payload, ) + assert isinstance(response, dict) assert response["refresh_token"] == token assert response["payload"] == payload -def test_get_access_response() -> None: +def test_get_access_response( + access_token_mock, +) -> None: + """ + Test that get_access_response returns correct response + """ + access_token = access_token_mock["access_token"] response = JWTHandler.get_access_response( token=access_token, ) - assert response["access_token"] == access_token assert isinstance(response, dict) + assert response["access_token"] == access_token -def test_generate_refresh() -> None: +def test_generate_refresh( + create_user_data, +) -> None: + """ + Test creation of refresh token + """ refresh_response = jwt_handler.generate_refresh( - create_data=create_data, + create_data=create_user_data, ) + assert isinstance(refresh_response, dict) assert isinstance(refresh_response["refresh_token"], str) assert isinstance(refresh_response["payload"], dict) - assert refresh_response["payload"]["email"] == "mail@example.com" + assert refresh_response["payload"]["email"] == create_user_data.email -def test_generate_access() -> None: +def test_generate_access( + refresh_tokens_mock, +) -> None: + """ + Test creation of access token + """ + token = refresh_tokens_mock["user_refresh_mock"] access_response = jwt_handler.generate_access( refresh_token=token, - extra_payload=extra_payload, + extra_payload={}, ) assert isinstance(access_response, dict) assert isinstance(access_response["access_token"], str) -def test_base_decode() -> None: +def test_base_decode( + refresh_tokens_mock, +) -> None: + """ + Test successful decoding of the token + """ + token = refresh_tokens_mock["user_refresh_mock"] refresh_payload = JWTHandler.base_decode(token) + assert isinstance(refresh_payload, dict) assert refresh_payload["token_type"] == "refresh" diff --git a/tests/services/utils/test_token_handler.py b/tests/services/utils/test_token_handler.py index aab97be..2833abe 100644 --- a/tests/services/utils/test_token_handler.py +++ b/tests/services/utils/test_token_handler.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - import pytest from fastapi.security import HTTPAuthorizationCredentials @@ -9,74 +7,85 @@ get_current_token_payload, token_handler, ) -from tests.services.utils.test_jwt_handler import ( - payload, - token, -) -def test_get_current_token_payload() -> None: +def test_get_current_token_payload( + refresh_tokens_mock, +) -> None: + """ + Test successful retrieval of payload from authorization credentials + """ + token = refresh_tokens_mock["user_refresh_mock"] + payload = refresh_tokens_mock["user_payload_mock"] credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) - with patch.object(token_handler, "verify_refresh") as mock_verify_refresh: - mock_verify_refresh.return_value = TokenData( - token=token, - payload=payload, - ) - token_data = get_current_token_payload(credentials) - assert isinstance(token_data, TokenData) - assert token_data.token == token - assert token_data.payload == payload - - -def test_requre_expired() -> None: + token_data = get_current_token_payload(credentials) + + assert isinstance(token_data, TokenData) + assert isinstance(token_data.payload, dict) + assert token_data.token == token + assert token_data.payload == payload + + +def test_requre_expired( + refresh_tokens_mock, +) -> None: + """ + Test that the token is correctly processed and returned as TokenData + """ + token = refresh_tokens_mock["user_refresh_mock"] token_data = token_handler.requre_expired(token) + + assert isinstance(token_data, TokenData) + assert token_data.token == token + + +def test_requre_token( + refresh_tokens_mock, +) -> None: + """ + Test that the token is correctly processed and returned as TokenData + """ + token = refresh_tokens_mock["user_refresh_mock"] + token_data = token_handler.requre_token(token) + assert isinstance(token_data, TokenData) assert token_data.token == token -def test_requre_token() -> None: - with patch.object(token_handler, "decode_token") as mock_decode: - mock_decode.return_value = token_handler.requre_expired(token) - token_data = token_handler.requre_expired(token) - assert isinstance(token_data, TokenData) - assert token_data.token == token - - -def test_verify_refresh() -> None: - with patch.object(token_handler, "requre_token") as mock_requre_token: - mock_requre_token.return_value = TokenData( - token=token, - payload={ - "token_type": "abc", - } - ) - with pytest.raises(TokenError) as invalid_token: - token_handler.verify_refresh(token) - - assert "Invalid token type. Refresh token required." in str(invalid_token.value) - - -def test_verify_access() -> None: - with patch.object(token_handler, "requre_token") as mock_requre_token: - mock_requre_token.return_value = TokenData( - token=token, - payload={ - "token_type": "abc", - } - ) - with pytest.raises(TokenError) as invalid_access: - token_handler.verify_access(token) - assert "Invalid token type. Access token required." in str(invalid_access.value) - - -def test_verify_admin() -> None: - with patch.object(token_handler, "requre_token") as mock_requre_token: - mock_requre_token.return_value = TokenData( - token=token, - payload={ - "role": "USER", - } - ) - with pytest.raises(TokenError) as verify_admin_error: - token_handler.verify_admin(token) - assert "Invalid role type. Admin required." in str(verify_admin_error.value) +def test_verify_refresh( + access_token_mock, +) -> None: + """ + Tess unsuccessful verification of the refresh token + """ + token = access_token_mock["access_token"] + + with pytest.raises(TokenError) as invalid_token: + token_handler.verify_refresh(token) + assert "Invalid token type. Refresh token required." in str(invalid_token.value) + + +def test_verify_access( + refresh_tokens_mock, +) -> None: + """ + Tess unsuccessful verification of the access token + """ + token = refresh_tokens_mock["user_refresh_mock"] + + with pytest.raises(TokenError) as invalid_access: + token_handler.verify_access(token) + assert "Invalid token type. Access token required." in str(invalid_access.value) + + +def test_verify_admin( + refresh_tokens_mock, +) -> None: + """ + Tess unsuccessful verification of the admin role + """ + token = refresh_tokens_mock["user_refresh_mock"] + + with pytest.raises(TokenError) as verify_admin_error: + token_handler.verify_admin(token) + assert "Invalid role type. Admin required." in str(verify_admin_error.value) From f5a371d1b673819c296257495adca7f2a57a9cc4 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Thu, 3 Jul 2025 23:55:33 +0300 Subject: [PATCH 07/11] tests: Test refactoring --- .coverage | Bin 69632 -> 69632 bytes .pre-commit-config.yaml | 6 +- auth_app/services/tokens.py | 7 -- tests/config/test_config.py | 22 ++-- tests/conftest.py | 72 ++++-------- tests/services/test_ses.py | 20 ++-- tests/services/test_tokens.py | 110 ++++++++++++------ tests/services/test_users.py | 102 ++++++++++------ tests/services/utils/test_general_services.py | 14 ++- tests/services/utils/test_jwt_handler.py | 10 +- tests/services/utils/test_token_handler.py | 28 +++-- 11 files changed, 218 insertions(+), 173 deletions(-) diff --git a/.coverage b/.coverage index de8757de0f189d0facb2f3b7cfce54d84b52f470..e4f795086966e83bca570e388187ec3733c59a31 100644 GIT binary patch delta 216 zcmZozz|ydQWdmCSqugZnh7isu-XLCQUPE5F&4L2EcqYsDa?}UxS$;!g_M+gDEE!=y#{4&_anDZg<4H2^xGq^}d}OAey7D|h4WPV= zM$+@-_%6%MOM5k$v|FUx)8`w{|IfqFU|%<<_S}c$A0Oq@{~7-Oku3k)@ZXKo^?#1f QuYHsHdsF@9pZ)9&0BX=vC;$Ke delta 182 zcmV;n07?IVpag)R1h58x0UwhGfK&-v4O0z74KfWMvk?%x43i#>2pe#`=hz&Zqf>N_ z4iGH_0SO{4mg94dn`55i&67cm89f^+1OW*+Dk=`xPxglqcP1`F+%v?UA>It}lOb*% zDFgutJ}E*^_L!gSWnSjXz6`=<5MF15n;~vSTt>XJn2k09D`+2{ea?LTGXMbo_;L0* k!;j&|f1LTp{Ns;(?8o?HFaPl$fA;a0@5}xHv-yt)fbkVh>;M1& diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 031d560..a818001 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: 7.2.0 hooks: - id: flake8 - exclude: ^(migrations/|auth_app/models/|tests/) + exclude: ^(migrations/) additional_dependencies: - flake8-bugbear @@ -30,7 +30,7 @@ repos: hooks: - id: black # args: [ "--check", "--diff" ] # set check only - files: ^(auth_app/(repositories|routers|schemes|services)/.*\.py)$ + files: ^(auth_app/(repositories|routers|schemes|services)|tests/.*\.py)$ exclude: ^migrations/ - repo: local @@ -51,7 +51,7 @@ repos: hooks: - id: mypy args: [--config-file=pyproject.toml] - exclude: ".*/migrations/.*$|.*/settings.*$|tests|.git.*$|alembic" + exclude: ".*/migrations/.*$|.*/settings.*$|.git.*$|alembic" require_serial: true - repo: https://github.com/pycqa/isort # formatter that sorts imports automatically diff --git a/auth_app/services/tokens.py b/auth_app/services/tokens.py index a9dded3..ebe1efa 100644 --- a/auth_app/services/tokens.py +++ b/auth_app/services/tokens.py @@ -1,8 +1,5 @@ from datetime import datetime -from aiobotocore.client import AioBaseClient -from redis.asyncio.client import Redis - from auth_app.config import jwt_settings from auth_app.exeptions.custom import ServiceError from auth_app.models import RefreshTokenORM @@ -27,13 +24,9 @@ def __init__( self, user_repo: UserRepo, token_repo: TokenRepo, - redis: Redis, - ses: AioBaseClient, ) -> None: self.__user_repo = user_repo self.__token_repo = token_repo - self.__redis = redis # pylint: disable=W0238 - self.__ses = ses # pylint: disable=W0238 @property def user_repo(self) -> UserRepo: diff --git a/tests/config/test_config.py b/tests/config/test_config.py index faf905a..126f898 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -8,40 +8,42 @@ PostgresSettings, RedisSettings, ) -from tests.conftest import ( - fake_env, -) def test_base_conf() -> None: assert issubclass(BaseConfig, BaseSettings) -def test_pg_settings(fake_env) -> None: +def test_pg_settings(fake_env: None) -> None: pg_settings = PostgresSettings() - assert pg_settings.postgres_dsn == "postgresql+asyncpg://test_user:test_pwd@localhost:5439/test_db" + assert ( + pg_settings.postgres_dsn + == "postgresql+asyncpg://test_user:test_pwd@localhost:5439/test_db" + ) -def test_redis_settings(fake_env) -> None: +def test_redis_settings(fake_env: None) -> None: redis_settings = RedisSettings() assert redis_settings.redis_dsn == "redis://:test_pwd@localhost:6380/0" -def test_jwt_settings(fake_env) -> None: +def test_jwt_settings(fake_env: None) -> None: jwt_settings = JWTSettings() assert str(jwt_settings.jwt_key) == "123" assert jwt_settings.ADMIN_SECRET.get_secret_value() == "test_secret" assert jwt_settings.ALGORITHM.get_secret_value() == "test_alg" -def test_pwd_settings(fake_env) -> None: +def test_pwd_settings(fake_env: None) -> None: pwd_settings = PasswordSettings() assert pwd_settings.HASHING_ALGORITHM.get_secret_value() == "test_alg" assert pwd_settings.HASHING_DEPRECATED.get_secret_value() == "test_depr" -def test_aws_settings(fake_env) -> None: +def test_aws_settings(fake_env: None) -> None: aws_settings = AWSSettings() assert aws_settings.AWS_ENDPOINT == "http://localhost:4566" assert aws_settings.AWS_DEFAULT_REGION == "default" - assert aws_settings.AWS_SECRET_ACCESS_KEY.get_secret_value() == "test_secret" + assert ( + aws_settings.AWS_SECRET_ACCESS_KEY.get_secret_value() == "test_secret" + ) diff --git a/tests/conftest.py b/tests/conftest.py index d24ff83..8081b25 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,9 @@ ) import pytest +from _pytest.monkeypatch import MonkeyPatch from aiobotocore.client import AioBaseClient +from pytest_mock import MockerFixture from redis.asyncio.client import Redis from auth_app.config import jwt_settings @@ -35,7 +37,9 @@ @pytest.fixture -def fake_env(monkeypatch): +def fake_env( + monkeypatch: MonkeyPatch, +) -> None: # POSTGRES DB monkeypatch.setenv("POSTGRES_USER", "test_user") monkeypatch.setenv("POSTGRES_PASSWORD", "test_pwd") @@ -71,27 +75,6 @@ def fake_env(monkeypatch): monkeypatch.setenv("VERIFICATION_CODE_LENGTH", "5") -@pytest.fixture -def mock_dependencies() -> dict: - return { - "user_repo": Mock( - create_user=AsyncMock(), - get_user=AsyncMock(), - update_user=AsyncMock(), - get_users=AsyncMock(), - ), - "token_repo": Mock( - create_refresh=AsyncMock(), - get_refresh=AsyncMock(), - update_refresh=AsyncMock(), - ), - "redis": Mock(), - "ses": Mock( - - ), - } - - @pytest.fixture def mock_user_repo() -> MagicMock: return MagicMock( @@ -118,7 +101,7 @@ def verification_code() -> str: @pytest.fixture def mock_redis( - verification_code, + verification_code: str, ) -> MagicMock: mock = MagicMock(spec=Redis) mock.set = AsyncMock(return_value=None) @@ -133,10 +116,10 @@ def mock_ses() -> MagicMock: @pytest.fixture def mock_user_service( - mock_user_repo, - mock_token_repo, - mock_redis, - mock_ses, + mock_user_repo: MagicMock, + mock_token_repo: MagicMock, + mock_redis: MagicMock, + mock_ses: MagicMock, ) -> UserService: return UserService( user_repo=mock_user_repo, @@ -148,16 +131,12 @@ def mock_user_service( @pytest.fixture def mock_token_service( - mock_user_repo, - mock_token_repo, - mock_redis, - mock_ses, + mock_user_repo: MagicMock, + mock_token_repo: MagicMock, ) -> TokenService: return TokenService( user_repo=mock_user_repo, token_repo=mock_token_repo, - redis=mock_redis, - ses=mock_ses, ) @@ -180,8 +159,8 @@ def user_id() -> UUID: @pytest.fixture(scope="session") def user_orm_mock( - get_user_data, - user_id + get_user_data: UserORM, + user_id: UUID, ) -> UserORM: return UserORM( id=user_id, @@ -195,8 +174,8 @@ def user_orm_mock( @pytest.fixture def verified_user_orm_mock( - get_user_data, - user_id + get_user_data: UserORM, + user_id: UUID, ) -> UserORM: return UserORM( id=user_id, @@ -220,7 +199,7 @@ def token_life_time_mock() -> dict: @pytest.fixture def valid_user_creation_data( - get_user_data, + get_user_data: UserORM, ) -> CreateUserExtendedScheme: return CreateUserExtendedScheme( email=get_user_data["email"], @@ -231,7 +210,7 @@ def valid_user_creation_data( @pytest.fixture def invalid_admin_creation_data( - get_user_data, + get_user_data: UserORM, ) -> CreateUserExtendedScheme: return CreateUserExtendedScheme( email=get_user_data["email"], @@ -243,9 +222,9 @@ def invalid_admin_creation_data( @pytest.fixture def refresh_user_payload( - token_life_time_mock, - get_user_data, - user_id, + token_life_time_mock: dict, + get_user_data: UserORM, + user_id: UUID, ) -> dict: return { "user_id": str(user_id), @@ -258,7 +237,7 @@ def refresh_user_payload( @pytest.fixture def mock_message_creator( - mocker, + mocker: MockerFixture, ) -> MagicMock: mock_msg = mocker.patch( "auth_app.services.users.msg_creator.get_code_message", @@ -298,7 +277,6 @@ def refresh_tokens_mock( ) -> dict: user_refresh_mock = token_handler.generate_refresh( create_user_data, - ) admin_refresh_mock = token_handler.generate_refresh( create_admin_data, @@ -313,7 +291,7 @@ def refresh_tokens_mock( @pytest.fixture(scope="session") def access_token_mock( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> dict: access_token = token_handler.generate_access( refresh_token=refresh_tokens_mock["user_refresh_mock"], @@ -324,7 +302,7 @@ def access_token_mock( @pytest.fixture(scope="session") def refresh_orm_mock( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> RefreshTokenORM: payload = refresh_tokens_mock["user_payload_mock"] @@ -339,7 +317,7 @@ def refresh_orm_mock( @pytest.fixture(scope="session") def get_user_scheme_mock( - user_orm_mock, + user_orm_mock: UserORM, ) -> GetUserScheme: return GetUserScheme( email=user_orm_mock.email, diff --git a/tests/services/test_ses.py b/tests/services/test_ses.py index 40eb828..a9d959b 100644 --- a/tests/services/test_ses.py +++ b/tests/services/test_ses.py @@ -1,9 +1,11 @@ from unittest.mock import ( AsyncMock, + MagicMock, ) import pytest from aiobotocore.client import AioBaseClient +from pytest_mock import MockerFixture from auth_app.config import aws_settings from auth_app.schemes.email import EmailPayloadScheme @@ -20,7 +22,7 @@ def generate_email_payload_data() -> dict: @pytest.fixture def send_email_patch( - mocker, + mocker: MockerFixture, ) -> AsyncMock: send_email = mocker.patch( "auth_app.services.ses.ses_handler.ses_handler.send_email", @@ -31,7 +33,7 @@ def send_email_patch( def test_generate_email_payload( - generate_email_payload_data, + generate_email_payload_data: dict, ) -> None: """ Test of valid email payload creation @@ -41,8 +43,8 @@ def test_generate_email_payload( ) assert isinstance(email_payload, EmailPayloadScheme) - assert email_payload.message == "Test message" - assert email_payload.subject == "Test subject" + assert email_payload.message == generate_email_payload_data["message"] + assert email_payload.subject == generate_email_payload_data["subject"] def test_generate_verification_code() -> None: @@ -57,8 +59,8 @@ def test_generate_verification_code() -> None: @pytest.mark.asyncio async def test_reset_password( - send_email_patch, - get_user_data, + send_email_patch: AsyncMock, + get_user_data: dict, ) -> None: """ Test of valid password reset @@ -77,9 +79,9 @@ async def test_reset_password( @pytest.mark.asyncio async def test_send_confirmation_email( - get_user_data, - mock_redis, - send_email_patch, + get_user_data: dict, + mock_redis: MagicMock, + send_email_patch: AsyncMock, ) -> None: """ Test of successful sending of the confirmation email diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index 22fb244..7dba2e4 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -1,22 +1,29 @@ +from typing import cast from unittest.mock import ( AsyncMock, MagicMock, ) import pytest +from pytest_mock import MockerFixture from auth_app.exeptions.custom import ServiceError -from auth_app.models import RefreshTokenORM +from auth_app.models import ( + RefreshTokenORM, + UserORM, +) from auth_app.schemes.tokens import RoleDataScheme from auth_app.schemes.users import ( AuthUserScheme, + GetUserScheme, ) +from auth_app.services.tokens import TokenService from auth_app.services.utils.token_handler import TokenData @pytest.fixture def auth_user_data_mock( - user_orm_mock, + user_orm_mock: UserORM, ) -> AuthUserScheme: return AuthUserScheme( email=user_orm_mock.email, @@ -26,34 +33,34 @@ def auth_user_data_mock( @pytest.fixture def generate_refresh_patch( - mocker, - refresh_tokens_mock, + mocker: MockerFixture, + refresh_tokens_mock: dict, ) -> MagicMock: refresh_mock = mocker.patch( "auth_app.services.tokens.token_handler.generate_refresh", return_value={ "refresh_token": refresh_tokens_mock["user_refresh_mock"], "payload": refresh_tokens_mock["user_payload_mock"], - } + }, ) return refresh_mock @pytest.fixture def token_data( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> TokenData: token_data = TokenData( token=refresh_tokens_mock["user_refresh_mock"], - payload=refresh_tokens_mock["user_payload_mock"] + payload=refresh_tokens_mock["user_payload_mock"], ) return token_data @pytest.fixture def requre_expired_patch( - mocker, - token_data, + mocker: MockerFixture, + token_data: TokenData, ) -> MagicMock: requre_expired = mocker.patch( "auth_app.services.tokens.token_handler.requre_expired", @@ -64,8 +71,8 @@ def requre_expired_patch( @pytest.fixture def authenticate_user_patch( - mocker, - get_user_scheme_mock + mocker: MockerFixture, + get_user_scheme_mock: GetUserScheme, ) -> AsyncMock: auth_mock = mocker.patch( "auth_app.services.tokens.authenticate_user", @@ -77,7 +84,7 @@ def authenticate_user_patch( @pytest.fixture def authenticate_user_failure_patch( - mocker, + mocker: MockerFixture, ) -> AsyncMock: auth_mock = mocker.patch( "auth_app.services.tokens.authenticate_user", @@ -89,15 +96,18 @@ def authenticate_user_failure_patch( @pytest.mark.asyncio async def test_get_refresh_token( - auth_user_data_mock, - authenticate_user_patch, - refresh_orm_mock, - mock_token_service, - + auth_user_data_mock: AuthUserScheme, + authenticate_user_patch: AsyncMock, + refresh_orm_mock: RefreshTokenORM, + mock_token_service: TokenService, ) -> None: """ Test refresh token retrieval and authentication call """ + mock_token_service.token_repo.get_refresh = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.token_repo.get_refresh, + ) mock_token_service.token_repo.get_refresh.return_value = refresh_orm_mock res = await mock_token_service.get_refresh_token(auth_user_data_mock) @@ -107,17 +117,27 @@ async def test_get_refresh_token( @pytest.mark.asyncio async def test_create_refresh_token( - auth_user_data_mock, - authenticate_user_patch, - generate_refresh_patch, - mock_token_service, - refresh_orm_mock, + auth_user_data_mock: AuthUserScheme, + authenticate_user_patch: AsyncMock, + generate_refresh_patch: MagicMock, + mock_token_service: TokenService, + refresh_orm_mock: dict, ) -> None: """ Test successful creation and validation of a refresh token """ + mock_token_service.token_repo.get_refresh = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.token_repo.get_refresh, + ) mock_token_service.token_repo.get_refresh.return_value = None - mock_token_service.token_repo.create_refresh.return_value = refresh_orm_mock + mock_token_service.token_repo.create_refresh = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.token_repo.create_refresh, + ) + mock_token_service.token_repo.create_refresh.return_value = ( + refresh_orm_mock + ) role_auth_data = RoleDataScheme( **auth_user_data_mock.model_dump(), ) @@ -130,9 +150,9 @@ async def test_create_refresh_token( @pytest.mark.asyncio async def test_create_refresh_token_failure( - auth_user_data_mock, - authenticate_user_failure_patch, - mock_token_service, + auth_user_data_mock: AuthUserScheme, + authenticate_user_failure_patch: AsyncMock, + mock_token_service: TokenService, ) -> None: """ Test unsuccessful creation of a refresh token @@ -147,15 +167,21 @@ async def test_create_refresh_token_failure( @pytest.mark.asyncio async def test_exchange_refresh_token( - mock_token_service, - refresh_orm_mock, - requre_expired_patch, - token_data, + mock_token_service: TokenService, + refresh_orm_mock: dict, + requre_expired_patch: MagicMock, + token_data: TokenData, ) -> None: """ Test successful exchange of a refresh token """ - mock_token_service.token_repo.update_refresh.return_value = refresh_orm_mock + mock_token_service.token_repo.update_refresh = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.token_repo.update_refresh, + ) + mock_token_service.token_repo.update_refresh.return_value = ( + refresh_orm_mock + ) res = await mock_token_service.exchange_refresh_token( token_data=token_data, ) @@ -166,13 +192,17 @@ async def test_exchange_refresh_token( @pytest.mark.asyncio async def test_create_access_token( - mock_token_service, - verified_user_orm_mock, - token_data, + mock_token_service: TokenService, + verified_user_orm_mock: UserORM, + token_data: TokenData, ) -> None: """ Test successful creation of an access token """ + mock_token_service.user_repo.get_user = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.user_repo.get_user, + ) mock_token_service.user_repo.get_user.return_value = verified_user_orm_mock res = await mock_token_service.create_access_token( token_data=token_data, @@ -183,16 +213,20 @@ async def test_create_access_token( @pytest.mark.asyncio async def test_create_access_token_failure( - mock_token_service, - user_orm_mock, - token_data, + mock_token_service: TokenService, + user_orm_mock: UserORM, + token_data: TokenData, ) -> None: """ Test unsuccessful creation of an access token """ with pytest.raises(ServiceError) as verification_error: + mock_token_service.user_repo.get_user = cast( # type: ignore[method-assign] + AsyncMock, + mock_token_service.user_repo.get_user, + ) mock_token_service.user_repo.get_user.return_value = user_orm_mock - res = await mock_token_service.create_access_token( + await mock_token_service.create_access_token( token_data=token_data, ) diff --git a/tests/services/test_users.py b/tests/services/test_users.py index f603b5a..13dcbe6 100644 --- a/tests/services/test_users.py +++ b/tests/services/test_users.py @@ -1,6 +1,11 @@ -from unittest.mock import AsyncMock, MagicMock +from typing import cast +from unittest.mock import ( + AsyncMock, + MagicMock, +) import pytest +from pytest_mock import MockerFixture from auth_app.exeptions.custom import ( ServiceError, @@ -10,29 +15,29 @@ from auth_app.models import UserORM from auth_app.schemes.users import ( CreateResponseScheme, + CreateUserExtendedScheme, GetUserScheme, MessageResponseScheme, RoleEnum, ) +from auth_app.services.users import UserService @pytest.fixture def send_confirmation_email_mock( - mocker, + mocker: MockerFixture, ) -> MagicMock: confirmation_email_mock = mocker.patch( "auth_app.services.users.ses_handler.send_confirmation_email", new_callable=AsyncMock, - return_value={ - "message": "message" - } + return_value={"message": "message"}, ) return confirmation_email_mock @pytest.fixture def ses_reset_pwd_mock( - mocker, + mocker: MockerFixture, ) -> None: mocker.patch( "auth_app.services.users.ses_handler.reset_password", @@ -40,20 +45,22 @@ def ses_reset_pwd_mock( return_value={ 'message': msg_creator.get_reset_pwd_message(), 'new_password': "new_password", - } + }, ) @pytest.mark.asyncio async def test_create_user_record_base( - mock_user_service, - user_orm_mock, - valid_user_creation_data, + mock_user_service: UserService, + user_orm_mock: UserORM, + valid_user_creation_data: CreateUserExtendedScheme, ) -> None: """ Test user creation with role USER and valid data """ - mock_user_service.user_repo.create_user.return_value = user_orm_mock + mock_user_service.user_repo.create_user = AsyncMock( # type: ignore[method-assign] + return_value=user_orm_mock, + ) user_orm = await mock_user_service.create_user_record( user_data=valid_user_creation_data, ) @@ -64,8 +71,8 @@ async def test_create_user_record_base( @pytest.mark.asyncio async def test_create_user_record_admin( - mock_user_service, - invalid_admin_creation_data, + mock_user_service: UserService, + invalid_admin_creation_data: CreateUserExtendedScheme, ) -> None: """ Test user creation with an invalid admin code for the ADMIN role @@ -80,10 +87,10 @@ async def test_create_user_record_admin( @pytest.mark.asyncio async def test_create_init_code_message( - user_orm_mock, - mock_user_service, - send_confirmation_email_mock, - mock_message_creator, + user_orm_mock: UserORM, + mock_user_service: UserService, + send_confirmation_email_mock: MagicMock, + mock_message_creator: MagicMock, ) -> None: """ Test message creation with initialization code and response structure @@ -100,10 +107,10 @@ async def test_create_init_code_message( @pytest.mark.asyncio async def test_create_verification_code( - mock_user_service, - refresh_user_payload, - send_confirmation_email_mock, - mock_message_creator, + mock_user_service: UserService, + refresh_user_payload: dict, + send_confirmation_email_mock: MagicMock, + mock_message_creator: MagicMock, ) -> None: """ Test verification code creation and response structure @@ -120,15 +127,17 @@ async def test_create_verification_code( @pytest.mark.asyncio async def test_execute_verification_success( - mock_user_service, - verified_user_orm_mock, - refresh_user_payload, - verification_code, + mock_user_service: UserService, + verified_user_orm_mock: UserORM, + refresh_user_payload: dict, + verification_code: str, ) -> None: """ Test successful execution of the verification """ - mock_user_service.user_repo.update_user.return_value = verified_user_orm_mock + mock_user_service.user_repo.update_user = AsyncMock( # type: ignore[method-assign] + return_value=verified_user_orm_mock, + ) user_data = await mock_user_service.execute_verification( verification_code=verification_code, payload=refresh_user_payload, @@ -140,39 +149,56 @@ async def test_execute_verification_success( @pytest.mark.asyncio async def test_execute_verification_failure( - mock_user_service, - verified_user_orm_mock, - refresh_user_payload, - verification_code, + mock_user_service: UserService, + verified_user_orm_mock: UserORM, + refresh_user_payload: dict, + verification_code: str, ) -> None: """ Test unsuccessful execution of the verification """ + mock_user_service.redis.get = cast( + AsyncMock, + mock_user_service.redis.get, + ) mock_user_service.redis.get.return_value = "WrongCode" with pytest.raises(UserVerificationError) as exc_info: - mock_user_service.user_repo.update_user.return_value = verified_user_orm_mock + mock_user_service.user_repo.update_user = cast( # type: ignore[method-assign] + AsyncMock, + mock_user_service.user_repo.update_user, + ) + mock_user_service.user_repo.update_user.return_value = ( + verified_user_orm_mock + ) await mock_user_service.execute_verification( verification_code=verification_code, payload=refresh_user_payload, ) - assert "The user is not verified or verification failed" in str(exc_info.value) + assert "The user is not verified or verification failed" in str( + exc_info.value + ) @pytest.mark.asyncio async def test_reset_password( - mock_user_service, - refresh_user_payload, - user_orm_mock, - ses_reset_pwd_mock, + mock_user_service: UserService, + refresh_user_payload: dict, + user_orm_mock: UserORM, + ses_reset_pwd_mock: None, ) -> None: """ Test successful execution of the password reset """ - mock_user_service.user_repo.update_user.return_value = user_orm_mock + mock_user_service.user_repo.update_user = AsyncMock( # type: ignore[method-assign] + return_value=user_orm_mock, + ) response = await mock_user_service.reset_password( payload=refresh_user_payload, ) assert isinstance(response, dict) - assert response["message"] == "Password was changed. Check your email to get it." + assert ( + response["message"] + == "Password was changed. Check your email to get it." + ) diff --git a/tests/services/utils/test_general_services.py b/tests/services/utils/test_general_services.py index 45d1df5..270b0ad 100644 --- a/tests/services/utils/test_general_services.py +++ b/tests/services/utils/test_general_services.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from auth_app.schemes.users import ( @@ -13,7 +15,7 @@ def test_hash_password( - get_user_data, + get_user_data: dict, ) -> None: """ Test that a password is hashed correctly and can be verified @@ -28,7 +30,7 @@ def test_hash_password( def test_verify_password( - get_user_data, + get_user_data: dict, ) -> None: """ Test successful password verification with correct password @@ -41,9 +43,9 @@ def test_verify_password( @pytest.mark.asyncio async def test_authenticate_user( - get_user_data, - get_user_scheme_mock, - mock_user_repo, + get_user_data: dict, + get_user_scheme_mock: GetUserScheme, + mock_user_repo: MagicMock, ) -> None: """ Test successful authentication with valid user data @@ -52,7 +54,7 @@ async def test_authenticate_user( result = await authenticate_user( email=get_user_data["email"], password=get_user_data["password"], - user_repo=mock_user_repo + user_repo=mock_user_repo, ) mock_user_repo.get_users.assert_called_once() diff --git a/tests/services/utils/test_jwt_handler.py b/tests/services/utils/test_jwt_handler.py index c8be61f..5a454d1 100644 --- a/tests/services/utils/test_jwt_handler.py +++ b/tests/services/utils/test_jwt_handler.py @@ -5,7 +5,7 @@ def test_get_refresh_response( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test that get_refresh_response returns correct response @@ -23,7 +23,7 @@ def test_get_refresh_response( def test_get_access_response( - access_token_mock, + access_token_mock: dict, ) -> None: """ Test that get_access_response returns correct response @@ -37,7 +37,7 @@ def test_get_access_response( def test_generate_refresh( - create_user_data, + create_user_data: CreateDataScheme, ) -> None: """ Test creation of refresh token @@ -53,7 +53,7 @@ def test_generate_refresh( def test_generate_access( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test creation of access token @@ -68,7 +68,7 @@ def test_generate_access( def test_base_decode( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test successful decoding of the token diff --git a/tests/services/utils/test_token_handler.py b/tests/services/utils/test_token_handler.py index 2833abe..10cd6f4 100644 --- a/tests/services/utils/test_token_handler.py +++ b/tests/services/utils/test_token_handler.py @@ -10,14 +10,16 @@ def test_get_current_token_payload( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test successful retrieval of payload from authorization credentials """ token = refresh_tokens_mock["user_refresh_mock"] payload = refresh_tokens_mock["user_payload_mock"] - credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", credentials=token + ) token_data = get_current_token_payload(credentials) assert isinstance(token_data, TokenData) @@ -27,7 +29,7 @@ def test_get_current_token_payload( def test_requre_expired( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test that the token is correctly processed and returned as TokenData @@ -40,7 +42,7 @@ def test_requre_expired( def test_requre_token( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Test that the token is correctly processed and returned as TokenData @@ -53,7 +55,7 @@ def test_requre_token( def test_verify_refresh( - access_token_mock, + access_token_mock: dict, ) -> None: """ Tess unsuccessful verification of the refresh token @@ -62,11 +64,13 @@ def test_verify_refresh( with pytest.raises(TokenError) as invalid_token: token_handler.verify_refresh(token) - assert "Invalid token type. Refresh token required." in str(invalid_token.value) + assert "Invalid token type. Refresh token required." in str( + invalid_token.value + ) def test_verify_access( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Tess unsuccessful verification of the access token @@ -75,11 +79,13 @@ def test_verify_access( with pytest.raises(TokenError) as invalid_access: token_handler.verify_access(token) - assert "Invalid token type. Access token required." in str(invalid_access.value) + assert "Invalid token type. Access token required." in str( + invalid_access.value + ) def test_verify_admin( - refresh_tokens_mock, + refresh_tokens_mock: dict, ) -> None: """ Tess unsuccessful verification of the admin role @@ -88,4 +94,6 @@ def test_verify_admin( with pytest.raises(TokenError) as verify_admin_error: token_handler.verify_admin(token) - assert "Invalid role type. Admin required." in str(verify_admin_error.value) + assert "Invalid role type. Admin required." in str( + verify_admin_error.value + ) From 692ea562b61f7acefdb3bb53e55c137283319e51 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Fri, 4 Jul 2025 00:59:35 +0300 Subject: [PATCH 08/11] feature: Create .yml for github actions --- .../gh_actions_tests_pre_commit.yaml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/gh_actions_tests_pre_commit.yaml diff --git a/.github/workflows/gh_actions_tests_pre_commit.yaml b/.github/workflows/gh_actions_tests_pre_commit.yaml new file mode 100644 index 0000000..d9feed2 --- /dev/null +++ b/.github/workflows/gh_actions_tests_pre_commit.yaml @@ -0,0 +1,77 @@ +name: GitHub Actions for the Skill-Tracker-Auth project +run-name: Skill-Tracker-Auth-Tests +on: [ push, pull_request ] +jobs: + run_tests: + runs-on: ubuntu-latest + env: + #POSTGRES + POSTGRES_DB: default_db + POSTGRES_USER: default_user + POSTGRES_PASSWORD: default_password + POSTGRES_HOST: localhost + POSTGRES_PORT: '5438' + # REDIS + REDIS_PASSWORD: default_password + REDIS_PORT: 6380 + REDIS_HOST: localhost + # PASSWORD HASHING + HASHING_ALGORITHM: bcrypt + HASHING_DEPRECATED: auto + # JWT SECRET KEY + KEY: secret_key + ALGORITHM: HS256 + REFRESH_LASTING: 3000 + ACCESS_LASTING: 300 + ADMIN_SECRET: default_secret + # LOCALSTACK + SERVICES: ses,s3 + AWS_DEFAULT_REGION: us-east-1 + LOCALSTACK_HOST: localstack + DEBUG: 1 + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + # LOCAL AWS + AWS_ENDPOINT: http://localhost:4566 + RESET_PWD_LENGTH: 10 + VERIFICATION_CODE_LENGTH: 5 + + steps: + - name: Checkout repository code + uses: actions/checkout@v4 + + - name: Setup Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Create external Docker network + run: docker network create app_network || true + + - name: Set up Docker Compose + run: docker compose -f docker-compose.yaml up -d + + - name: Set up Docker Compose for localstack + run: docker compose -f docker-compose.localstack.yaml up -d + + - name: Wait for DB to be ready + run: | + echo "Waiting for PostgreSQL to be ready..." + until docker exec skills_auth_pg pg_isready -U $POSTGRES_USER; do + sleep 1 + done + + - name: Install requirements + run: | + python -m pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false + poetry install + + - name: Run Tests + run: | + poetry run pytest + + - name: Run pre-commit hooks + run: | + poetry run pre-commit From 31ce0ef972cdf70855f351ce9e0afc9ae145b665 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Tue, 8 Jul 2025 00:42:01 +0300 Subject: [PATCH 09/11] feat: Add access token verification router and relevant tests --- .coverage | Bin 69632 -> 69632 bytes auth_app/dependencies.py | 4 +--- auth_app/routers/tokens.py | 19 ++++++++++++++++++- auth_app/schemes/tokens.py | 12 ++++++++++++ auth_app/services/tokens.py | 10 ++++++++++ tests/services/test_tokens.py | 33 ++++++++++++++++++++++++++++++++- 6 files changed, 73 insertions(+), 5 deletions(-) diff --git a/.coverage b/.coverage index e4f795086966e83bca570e388187ec3733c59a31..c9f35d9715e58c63815a7ba8df134575ca74b0fa 100644 GIT binary patch delta 406 zcmV;H0crk#pag)R1h58xUJw8e`47kszz?nurVpDBlMjave-C93L=QXed9!HO(2M{d;0SO{4mg94dn`55i&9MOu zECc}w94smcjvmMH<1+>;1OW*SD*_)X1OW*^Du%^@{LBCFV>=%wfAsUmJb&!WANTEy zKQ<31DFgutP$`B*jQQh#@|S=4FaPq#ZT@j@>-@O+<2Li-98P|`lgH)p0um?$0SP83 z!kv$QIp#$3aXvqu0h6nZf({HI1OW*eAPQhU&gc07lRA!u0qc{&j%F14!NI<}ey{p9 zzRjn8-}6(w-SrrgJC7+E7zzXd2{H;U2tMAuXWpH8cW2&b-kE2f`52RZk4Z5v0t5jG zU;-k9ZycWYIrI6?A3x4N=l}7K|2Y4T|HmKy_>cd`U;g7i{`T>g@5}!Iv-yt)fZrgc AMgRZ+ delta 392 zcmV;30eAj@pag)R1h58xULF7s`47eqz7MPqqz{=7kq?CreGg<0Lk~L-AP*Q13=aej z_YUt4-wxLf%?`s3vJRXMfDU*LYYt-$Ob$X0Iu0!kB@P=76AlIq=?$|Ds|}b9cnw+& zQVl~5F%2HG5fHize-SPO0SP27Hc-5$*c6+hQ*??B5G@1&2_h|);&Y0dVxHnnu>lP% z1OW*gEJ}i+M{$h!3e? zDFgutJ}E*^_L!gSWnSjXz6@eBh}Ri$GsKw@CnKH_C2y@Qo`I9@2h5_l5!j5JX+7Ax)-(9~~{TkoqQ@`)|sow4alRS?p z85ar!0SPb)E(ku}y=UH?d3R^tXWp4-o&l48k4ZBz0t5jGWC9|D8;56~GoSwq0DwP! moPEylWBBnOXZ|t&_+uaYG5*-gfBeUvee7lXvVXJrj|YI)7LM=$ diff --git a/auth_app/dependencies.py b/auth_app/dependencies.py index 1f51d2c..3ea2567 100644 --- a/auth_app/dependencies.py +++ b/auth_app/dependencies.py @@ -24,9 +24,7 @@ async def get_user_service( async def get_token_service( session: AsyncSession = Depends(get_db_from_request), - redis: Redis = Depends(get_redis_client), - ses: AioBaseClient = Depends(get_ses_client), ) -> TokenService: user_repo = UserRepo(session) token_repo = TokenRepo(session) - return TokenService(user_repo, token_repo, redis, ses) + return TokenService(user_repo, token_repo) diff --git a/auth_app/routers/tokens.py b/auth_app/routers/tokens.py index 54ea1e8..0b6586c 100644 --- a/auth_app/routers/tokens.py +++ b/auth_app/routers/tokens.py @@ -13,6 +13,7 @@ GetAccessScheme, GetRefreshScheme, RoleDataScheme, + VerifyAccessScheme, ) from auth_app.schemes.users import ( AuthUserScheme, @@ -88,7 +89,7 @@ async def exchange_refresh( @token_router.post( path='/access/create', response_model=GetAccessScheme, - description='Generate access token for the user', + description='Generate new access token for the user', status_code=status.HTTP_201_CREATED, ) async def create_access( @@ -99,3 +100,19 @@ async def create_access( token_data=token_data, ) return GetAccessScheme(message=token) + + +@token_router.post( + path='/access/verify/', + response_model=VerifyAccessScheme, + description='Verify the access token', + status_code=status.HTTP_200_OK, +) +async def verify_access( + access_token: Annotated[str, Body()], + token_service: TokenService = Depends(get_token_service), +) -> VerifyAccessScheme: + await token_service.verify_access_token( + access_token=access_token, + ) + return VerifyAccessScheme() diff --git a/auth_app/schemes/tokens.py b/auth_app/schemes/tokens.py index 1b27eb5..d3bd584 100644 --- a/auth_app/schemes/tokens.py +++ b/auth_app/schemes/tokens.py @@ -134,3 +134,15 @@ class GetAccessScheme(BaseModel): class Config: from_attributes = True + + +class VerifyAccessScheme(BaseModel): + message: dict[str, str] = Field( + description='Verification info', + default={ + 'message': 'The access token has been verified', + }, + ) + + class Config: + from_attributes = True diff --git a/auth_app/services/tokens.py b/auth_app/services/tokens.py index ebe1efa..28b8807 100644 --- a/auth_app/services/tokens.py +++ b/auth_app/services/tokens.py @@ -144,3 +144,13 @@ async def create_access_token( extra_payload=extra_payload, ) return access_token + + @staticmethod + async def verify_access_token( + access_token: str, + ) -> None: + """ + Verify access token validity + """ + token_handler.verify_access(access_token) + token_handler.requre_token(access_token) diff --git a/tests/services/test_tokens.py b/tests/services/test_tokens.py index 7dba2e4..0c1f265 100644 --- a/tests/services/test_tokens.py +++ b/tests/services/test_tokens.py @@ -7,7 +7,10 @@ import pytest from pytest_mock import MockerFixture -from auth_app.exeptions.custom import ServiceError +from auth_app.exeptions.custom import ( + ServiceError, + TokenError, +) from auth_app.models import ( RefreshTokenORM, UserORM, @@ -231,3 +234,31 @@ async def test_create_access_token_failure( ) assert "User must be verified" in str(verification_error.value) + + +@pytest.mark.asyncio +async def test_verify_access_token_success( + access_token_mock: dict, +) -> None: + """ + Test successful verification of an access token + """ + token = access_token_mock["access_token"] + await TokenService.verify_access_token( + access_token=token, + ) + + +@pytest.mark.asyncio +async def test_verify_access_token_failure( + refresh_tokens_mock: dict, +) -> None: + """ + Test unsuccessful verification of an access token + """ + token = refresh_tokens_mock["user_refresh_mock"] + with pytest.raises(TokenError) as token_error: + await TokenService.verify_access_token( + access_token=token, + ) + assert "Invalid token type" in str(token_error.value) From 8b6226cac67117b356400d5d00ebea5b1a810c14 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Fri, 11 Jul 2025 00:50:15 +0300 Subject: [PATCH 10/11] feat: Add CreateUser feature on the core side. Other edits. --- auth_app/config.py | 6 +++ auth_app/main.py | 15 +++++- auth_app/middleware/cors.py | 14 ++++++ auth_app/repositories/users.py | 15 ++++++ auth_app/routers/tokens.py | 19 +++++--- auth_app/schemes/tokens.py | 8 +--- auth_app/services/tokens.py | 14 ++++-- auth_app/services/users.py | 50 ++++++++++++++++++- auth_app/services/utils/token_handler.py | 22 ++++++++- poetry.lock | 61 +++++++++++++++++++++++- pyproject.toml | 3 +- 11 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 auth_app/middleware/cors.py diff --git a/auth_app/config.py b/auth_app/config.py index 9ffe341..257cc6c 100644 --- a/auth_app/config.py +++ b/auth_app/config.py @@ -74,8 +74,14 @@ class AWSSettings(BaseConfig): VERIFICATION_CODE_LENGTH: int +class CoreServiceSettings(BaseConfig): + CREATE_USER_URI: str + SERVICE_SECRET: SecretStr + + pg_settings = PostgresSettings() redis_settings = RedisSettings() jwt_settings = JWTSettings() pwd_settings = PasswordSettings() aws_settings = AWSSettings() +core_service_settings = CoreServiceSettings() diff --git a/auth_app/main.py b/auth_app/main.py index 4bbfe91..0239fbc 100644 --- a/auth_app/main.py +++ b/auth_app/main.py @@ -1,5 +1,6 @@ import uvicorn from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from auth_app.exeptions.custom import ( ServiceError, @@ -16,6 +17,7 @@ user_verification_exception_handler, ) from auth_app.messages.common import msg_creator +from auth_app.middleware.cors import cors_settings from auth_app.middleware.db_session import DBSessionMiddleware from auth_app.routers.tokens import token_router from auth_app.routers.users import user_router @@ -33,6 +35,13 @@ app.add_exception_handler(TransactionError, transaction_error_handler) app.add_middleware(DBSessionMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=cors_settings.allow_origins, + allow_credentials=cors_settings.allow_credentials, + allow_methods=cors_settings.allow_methods, + allow_headers=cors_settings.allow_headers +) @app.on_event("startup") @@ -54,4 +63,8 @@ async def root() -> dict: if __name__ == '__main__': - uvicorn.run('auth_app.main:app') + uvicorn.run( + 'auth_app.main:app', + host="localhost", + port=8001, + ) diff --git a/auth_app/middleware/cors.py b/auth_app/middleware/cors.py new file mode 100644 index 0000000..8485b69 --- /dev/null +++ b/auth_app/middleware/cors.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class CORSSettings(BaseModel): + allow_origins: list[str] = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + ] + allow_credentials: bool = True + allow_methods: list[str] = ["*"] + allow_headers: list[str] = ["*"] + + +cors_settings = CORSSettings() diff --git a/auth_app/repositories/users.py b/auth_app/repositories/users.py index 660ed21..f181b6d 100644 --- a/auth_app/repositories/users.py +++ b/auth_app/repositories/users.py @@ -2,6 +2,7 @@ from uuid import UUID from sqlalchemy import ( + delete, select, update, ) @@ -78,3 +79,17 @@ async def update_user( row = await self.session.execute(stmt) user_orm = row.scalars().first() return user_orm + + async def delete_user( + self, + user_id: UUID, + ) -> UserORM | None: + stmt = ( + delete(UserORM) + .where(UserORM.id == user_id) + .returning(UserORM) + ) + + row = await self.session.execute(stmt) + user_orm = row.scalars().first() + return user_orm diff --git a/auth_app/routers/tokens.py b/auth_app/routers/tokens.py index 0b6586c..be628ab 100644 --- a/auth_app/routers/tokens.py +++ b/auth_app/routers/tokens.py @@ -4,6 +4,7 @@ APIRouter, Body, Depends, + Header, HTTPException, status, ) @@ -24,6 +25,7 @@ from auth_app.services.utils.token_handler import ( TokenData, get_current_token_payload, + get_current_token_payload_for_exchange, ) token_router = APIRouter( @@ -77,7 +79,7 @@ async def create_refresh( status_code=status.HTTP_201_CREATED, ) async def exchange_refresh( - token_data: TokenData = Depends(get_current_token_payload), + token_data: TokenData = Depends(get_current_token_payload_for_exchange), token_service: TokenService = Depends(get_token_service), ) -> GetRefreshScheme: token = await token_service.exchange_refresh_token( @@ -102,17 +104,22 @@ async def create_access( return GetAccessScheme(message=token) -@token_router.post( +@token_router.get( path='/access/verify/', response_model=VerifyAccessScheme, description='Verify the access token', status_code=status.HTTP_200_OK, ) async def verify_access( - access_token: Annotated[str, Body()], + auth_data: Annotated[str, Header(alias="Authorization")], token_service: TokenService = Depends(get_token_service), ) -> VerifyAccessScheme: - await token_service.verify_access_token( - access_token=access_token, + if not auth_data.startswith("Bearer"): + raise HTTPException( + detail="Invalid Authorization header", + status_code=status.HTTP_401_UNAUTHORIZED, + ) + token_data = await token_service.verify_access_token( + auth_data=auth_data, ) - return VerifyAccessScheme() + return token_data diff --git a/auth_app/schemes/tokens.py b/auth_app/schemes/tokens.py index d3bd584..cee7b99 100644 --- a/auth_app/schemes/tokens.py +++ b/auth_app/schemes/tokens.py @@ -137,12 +137,8 @@ class Config: class VerifyAccessScheme(BaseModel): - message: dict[str, str] = Field( - description='Verification info', - default={ - 'message': 'The access token has been verified', - }, - ) + token: str + payload: dict class Config: from_attributes = True diff --git a/auth_app/services/tokens.py b/auth_app/services/tokens.py index 28b8807..68a1e33 100644 --- a/auth_app/services/tokens.py +++ b/auth_app/services/tokens.py @@ -10,6 +10,7 @@ CreateRefreshScheme, RoleDataScheme, UpdateRefreshScheme, + VerifyAccessScheme, ) from auth_app.schemes.users import AuthUserScheme from auth_app.services.utils.authenticate_user import authenticate_user @@ -78,8 +79,8 @@ async def create_refresh_token( ) token_data = token_handler.generate_refresh(create_data) payload = token_data["payload"] - expires_raw = payload["expires"] + if not isinstance(expires_raw, (float, int)): raise ValueError("expires must be a number") @@ -147,10 +148,15 @@ async def create_access_token( @staticmethod async def verify_access_token( - access_token: str, - ) -> None: + auth_data: str, + ) -> VerifyAccessScheme: """ Verify access token validity """ - token_handler.verify_access(access_token) + access_token = auth_data.split(" ")[1] + token_data = token_handler.verify_access(access_token) token_handler.requre_token(access_token) + return VerifyAccessScheme( + token=token_data.token, + payload=token_data.payload, + ) diff --git a/auth_app/services/users.py b/auth_app/services/users.py index 5b3f266..676539f 100644 --- a/auth_app/services/users.py +++ b/auth_app/services/users.py @@ -1,9 +1,14 @@ +import random from uuid import UUID +import httpx from aiobotocore.client import AioBaseClient from redis.asyncio.client import Redis -from auth_app.config import jwt_settings +from auth_app.config import ( + core_service_settings, + jwt_settings, +) from auth_app.exeptions.custom import ( ServiceError, UserActivityError, @@ -82,8 +87,43 @@ async def create_user_record( ): raise ServiceError("Invalid role or permission code") record = await self.__user_repo.create_user(user_data) + try: + await self.create_user_record_in_core( + record=record, + ) + except ServiceError as e: + await self.__user_repo.delete_user( + user_id=record.id, + ) + raise ServiceError( + f"Failed user creation on the core side: {e}" + ) from e return record + async def create_user_record_in_core( + self, + record: UserORM, + ) -> None: + headers = { + "Service-Secret": f"{core_service_settings.SERVICE_SECRET.get_secret_value()}" + } + user_name = self.create_unique_username(record.email) + user_data = { + "id": str(record.id), + "name": user_name, + "email": record.email, + } + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + core_service_settings.CREATE_USER_URI, + headers=headers, + json=user_data, + ) + if response.status_code != 201: + raise ServiceError( + f"Core service rejected user: {response.text}" + ) + async def create_init_code_message( self, record: UserORM, @@ -165,3 +205,11 @@ async def reset_password( "message": data.get("message"), } return response + + def create_unique_username( + self, + email: str, + ) -> str: + username = email.split("@")[0] + username = username + "_" + str(random.randint(100000, 999999)) + return username diff --git a/auth_app/services/utils/token_handler.py b/auth_app/services/utils/token_handler.py index c3d420e..75c7aa5 100644 --- a/auth_app/services/utils/token_handler.py +++ b/auth_app/services/utils/token_handler.py @@ -38,15 +38,24 @@ def requre_token( payload = self.decode_token(token=token) return TokenData(token=token, payload=payload) - def verify_refresh( + def verify_expired_refresh( self, token: str, ) -> TokenData: - token, payload = self.requre_token(token) + token, payload = self.requre_expired(token) if payload.get("token_type") != "refresh": raise TokenError("Invalid token type. Refresh token required.") return TokenData(token=token, payload=payload) + def verify_refresh( + self, + token: str, + ) -> TokenData: + token_data = self.verify_expired_refresh(token=token) + token, _ = token_data + self.requre_token(token=token) + return token_data + def verify_access( self, token: str, @@ -76,3 +85,12 @@ def get_current_token_payload( ) -> TokenData: token_data = token_handler.verify_refresh(token.credentials) return token_data + + +def get_current_token_payload_for_exchange( + token: HTTPAuthorizationCredentials = Security( + token_handler.oauth2_scheme + ), +) -> TokenData: + token_data = token_handler.verify_expired_refresh(token.credentials) + return token_data diff --git a/poetry.lock b/poetry.lock index b03192b..e477649 100644 --- a/poetry.lock +++ b/poetry.lock @@ -519,6 +519,18 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version > [package.extras] crt = ["awscrt (==0.23.8)"] +[[package]] +name = "certifi" +version = "2025.6.15" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, + {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, +] + [[package]] name = "cffi" version = "1.17.1" @@ -1129,6 +1141,53 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "identify" version = "2.6.12" @@ -2758,4 +2817,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "a8a81582fc3841b9ba4b511a716e17a6dba08666c47bf4d942b7d20ffa4501e0" +content-hash = "cb58d27e0386ce163b880c309be8f2f8659e9a22a51f4139f3045fd358b3dc47" diff --git a/pyproject.toml b/pyproject.toml index 7245ddb..8fc9cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ dependencies = [ "bcrypt (<4.1.0)", "coverage (>=7.9.1,<8.0.0)", "pytest-asyncio (>=1.0.0,<2.0.0)", - "pytest-mock (>=3.14.1,<4.0.0)" + "pytest-mock (>=3.14.1,<4.0.0)", + "httpx (>=0.28.1,<0.29.0)" ] [tool.poetry] From edd1dfbddf712a2e141bd66235b99b13c5677e92 Mon Sep 17 00:00:00 2001 From: AlexandrSmolyachkovGH Date: Tue, 15 Jul 2025 00:50:09 +0300 Subject: [PATCH 11/11] feat: add soft_delete routers --- auth_app/config.py | 1 + auth_app/repositories/users.py | 47 ++++++++++++++++++++++++++++++++++ auth_app/routers/users.py | 28 ++++++++++++++++++++ auth_app/schemes/users.py | 2 +- auth_app/services/users.py | 47 ++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/auth_app/config.py b/auth_app/config.py index 257cc6c..af27e51 100644 --- a/auth_app/config.py +++ b/auth_app/config.py @@ -76,6 +76,7 @@ class AWSSettings(BaseConfig): class CoreServiceSettings(BaseConfig): CREATE_USER_URI: str + DELETE_USER_URI: str SERVICE_SECRET: SecretStr diff --git a/auth_app/repositories/users.py b/auth_app/repositories/users.py index f181b6d..0d3dc71 100644 --- a/auth_app/repositories/users.py +++ b/auth_app/repositories/users.py @@ -89,6 +89,53 @@ async def delete_user( .where(UserORM.id == user_id) .returning(UserORM) ) + print(f"stmt: {stmt}") + + row = await self.session.execute(stmt) + print(f"row: {row}") + user_orm = row.scalars().first() + return user_orm + + async def soft_delete_user( + self, + user_id: UUID, + ) -> UserORM: + user = await self.get_user(user_id=user_id) + if user is None: + raise ValueError(f"User with id {user_id} not found") + data = { + "email": "deleted_" + user.email, + "is_active": False, + } + stmt = ( + update(UserORM) + .where(UserORM.id == user_id) + .values(**data) + .returning(UserORM) + ) + + row = await self.session.execute(stmt) + user_orm = row.scalars().first() + return user_orm + + async def soft_delete_user_rollback( + self, + user_id: UUID, + ) -> UserORM: + user = await self.get_user(user_id=user_id) + if user is None: + raise ValueError(f"User with id {user_id} not found") + email = str(user.email).replace("deleted_", "") + data = { + "email": email, + "is_active": True, + } + stmt = ( + update(UserORM) + .where(UserORM.id == user_id) + .values(**data) + .returning(UserORM) + ) row = await self.session.execute(stmt) user_orm = row.scalars().first() diff --git a/auth_app/routers/users.py b/auth_app/routers/users.py index 08076c0..794c1b1 100644 --- a/auth_app/routers/users.py +++ b/auth_app/routers/users.py @@ -1,4 +1,5 @@ from typing import Annotated +from uuid import UUID from fastapi import ( APIRouter, @@ -13,6 +14,7 @@ from auth_app.schemes.users import ( CreateResponseScheme, CreateUserExtendedScheme, + DeleteUserScheme, GetUserScheme, MessageResponseScheme, UserFilterScheme, @@ -148,3 +150,29 @@ async def get_users( detail='Relevant users not found', ) return [GetUserScheme.model_validate(user) for user in users] + + +@user_router.delete( + path='/{user_id}', + response_model=GetUserScheme, + description="Delete the user", + status_code=status.HTTP_200_OK, +) +async def delete_user( + user_id: UUID, + token_data: TokenData = Depends(get_current_token_payload), + user_service: UserService = Depends(get_user_service), +) -> GetUserScheme: + delete_model = DeleteUserScheme( + id=user_id, + ) + deleted_user = await user_service.delete_user_record( + token_data=token_data, + delete_model=delete_model, + ) + if not deleted_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found or already deleted', + ) + return GetUserScheme.model_validate(deleted_user) diff --git a/auth_app/schemes/users.py b/auth_app/schemes/users.py index 4388938..3ebc5bc 100644 --- a/auth_app/schemes/users.py +++ b/auth_app/schemes/users.py @@ -127,7 +127,7 @@ class Config: from_attributes = True -class DeleteUserScheme(AuthUserScheme): +class DeleteUserScheme(BaseModel): id: Optional[UUID] = Field( description='Unique user identifier ', example='123e4567-e89b-12d3-a456-426614174000', diff --git a/auth_app/services/users.py b/auth_app/services/users.py index 676539f..b79a8b5 100644 --- a/auth_app/services/users.py +++ b/auth_app/services/users.py @@ -21,6 +21,7 @@ from auth_app.schemes.users import ( CreateResponseScheme, CreateUserExtendedScheme, + DeleteUserScheme, GetUserScheme, MessageResponseScheme, PatchUserScheme, @@ -28,6 +29,7 @@ ) from auth_app.services.ses.ses_handler import ses_handler from auth_app.services.utils.pwd_hashing import hash_password +from auth_app.services.utils.token_handler import TokenData class UserService: @@ -213,3 +215,48 @@ def create_unique_username( username = email.split("@")[0] username = username + "_" + str(random.randint(100000, 999999)) return username + + async def delete_user_record( + self, + token_data: TokenData, + delete_model: DeleteUserScheme, + ) -> UserORM: + role = token_data.payload["role"] + user_id = token_data.payload["user_id"] + if delete_model.id is None: + raise ServiceError("User ID must not be None") + if role in ["USER"] and user_id != str(delete_model.id): + raise ServiceError("Have no permissions to delete the record") + record = await self.__user_repo.soft_delete_user( + user_id=delete_model.id, + ) + try: + await self.delete_user_record_in_core( + user_id=delete_model.id, + ) + except ServiceError as e: + await self.__user_repo.soft_delete_user_rollback( + user_id=record.id, + ) + raise ServiceError( + f"Delete failure on the core side: {e}" + ) from e + return record + + async def delete_user_record_in_core( + self, + user_id: UUID, + ) -> None: + headers = { + "Service-Secret": f"{core_service_settings.SERVICE_SECRET.get_secret_value()}" + } + uri = core_service_settings.DELETE_USER_URI + str(user_id) + "/" + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.delete( + uri, + headers=headers, + ) + if response.status_code != 200: + raise ServiceError( + f"Core service rejected delete operation: {response.text}" + )