From 8266c95cd9cac06ab7171bbc994511b36d1730af Mon Sep 17 00:00:00 2001 From: Ivan Golikov Date: Wed, 1 Jan 2025 18:18:38 +0000 Subject: [PATCH] Integration tests (#3) Reviewed-on: https://git.ivnglkv.me/root/pssecret/pulls/3 Co-authored-by: Ivan Golikov Co-committed-by: Ivan Golikov --- .flake8 | 3 - .gitignore | 2 +- .pre-commit-config.yaml | 5 +- poetry.lock | 364 +++++++++++++++++++++++++++++++- pssecret/main.py | 22 +- pssecret/redis_db.py | 9 +- pssecret/settings.py | 3 +- pssecret/utils.py | 15 +- pyproject.toml | 13 ++ tests/__init__.py | 0 tests/conftest.py | 38 ++++ tests/factories.py | 6 + tests/integration/__init__.py | 0 tests/integration/test_api.py | 33 +++ tests/integration/test_utils.py | 35 +++ 15 files changed, 527 insertions(+), 21 deletions(-) delete mode 100644 .flake8 create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/factories.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/integration/test_utils.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index f090a69..0000000 --- a/.flake8 +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length = 88 -extend-ignore = E203, B008 diff --git a/.gitignore b/.gitignore index 8ea7c0a..563dd65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.env +.*env .idea/ .nvim.lua .python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63c854d..3d8c1a6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,10 @@ repos: rev: 7.1.1 hooks: - id: flake8 - args: [--config, .flake8] + args: + - --max-line-length=88 + - --extend-ignore=E203,B008 + - --per-file-ignores=tests/factories.py:E701 additional_dependencies: - flake8-bugbear - flake8-comprehensions diff --git a/poetry.lock b/poetry.lock index 20cb268..754e86a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,6 +32,21 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] +[[package]] +name = "asttokens" +version = "3.0.0" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, + {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, +] + +[package.extras] +astroid = ["astroid (>=2,<4)"] +test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] + [[package]] name = "async-timeout" version = "5.0.1" @@ -79,6 +94,17 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -114,6 +140,35 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" +[[package]] +name = "executing" +version = "2.1.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +files = [ + {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, + {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "faker" +version = "33.1.0" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"}, + {file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" +typing-extensions = "*" + [[package]] name = "fastapi" version = "0.115.6" @@ -402,6 +457,73 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "ipython" +version = "8.31.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.31.0-py3-none-any.whl", hash = "sha256:46ec58f8d3d076a61d128fe517a51eb730e3aaf0c184ea8c17d16e366660c6a6"}, + {file = "ipython-8.31.0.tar.gz", hash = "sha256:b6a2274606bec6166405ff05e54932ed6e5cfecaca1fc05f2cacde7bb074d70b"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt_toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack_data = "*" +traitlets = ">=5.13.0" +typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.19.2" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, +] + +[package.dependencies] +parso = ">=0.8.4,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] + [[package]] name = "jinja2" version = "3.1.5" @@ -513,6 +635,20 @@ files = [ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mdurl" version = "0.1.2" @@ -524,6 +660,124 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "polyfactory" +version = "2.18.1" +description = "Mock data generation factories" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "polyfactory-2.18.1-py3-none-any.whl", hash = "sha256:1a2b0715e08bfe9f14abc838fc013ab8772cb90e66f2e601e15e1127f0bc1b18"}, + {file = "polyfactory-2.18.1.tar.gz", hash = "sha256:17c9db18afe4fb8d7dd8e5ba296e69da0fcf7d0f3b63d1840eb10d135aed5aad"}, +] + +[package.dependencies] +faker = "*" +typing-extensions = ">=4.6.0" + +[package.extras] +attrs = ["attrs (>=22.2.0)"] +beanie = ["beanie", "pydantic[email]"] +full = ["attrs", "beanie", "msgspec", "odmantic", "pydantic", "sqlalchemy"] +msgspec = ["msgspec"] +odmantic = ["odmantic (<1.0.0)", "pydantic[email]"] +pydantic = ["pydantic[email]"] +sqlalchemy = ["sqlalchemy (>=1.4.29)"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pydantic" version = "2.10.4" @@ -690,6 +944,58 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.25.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, + {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, +] + +[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" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.0.1" @@ -840,6 +1146,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -851,6 +1168,25 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "starlette" version = "0.41.3" @@ -868,6 +1204,21 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "typer" version = "0.15.1" @@ -1055,6 +1406,17 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "websockets" version = "14.1" @@ -1139,4 +1501,4 @@ hiredis = ["hiredis"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1f2ca7562492fce7198a033828bce3cd8b9ed4cd38fc9e5c35784113bb816827" +content-hash = "9f0f96c653fdc92b7a1ed18be32ff88814ac5188b207fda1ea7e672a7cd19d6c" diff --git a/pssecret/main.py b/pssecret/main.py index 5d2b771..e385268 100644 --- a/pssecret/main.py +++ b/pssecret/main.py @@ -1,12 +1,17 @@ -from fastapi import FastAPI +from typing import Annotated + +from fastapi import Depends, FastAPI from fastapi.exceptions import HTTPException +from redis.asyncio import Redis from pssecret.models import Secret, SecretSaveResult -from pssecret.redis_db import redis -from pssecret.utils import get_new_key +from pssecret.redis_db import get_redis +from pssecret.utils import save_secret app = FastAPI() +RedisDep = Annotated[Redis, Depends(get_redis)] + @app.post( "/secret", @@ -18,12 +23,9 @@ app = FastAPI() ), response_model=SecretSaveResult, ) -async def set_secret(data: Secret): - new_key = await get_new_key() - await redis.setex(new_key, 60 * 60 * 24, data.data) - +async def set_secret(data: Secret, redis: RedisDep) -> dict[str, str]: return { - "key": new_key, + "key": await save_secret(data, redis), } @@ -38,8 +40,8 @@ async def set_secret(data: Secret): response_model=Secret, responses={404: {"description": "The item was not found"}}, ) -async def get_secret(secret_key: str): - data: str | None = await redis.getdel(secret_key) +async def get_secret(secret_key: str, redis: RedisDep) -> dict[str, bytes]: + data: bytes | None = await redis.getdel(secret_key) if data is None: raise HTTPException(404) diff --git a/pssecret/redis_db.py b/pssecret/redis_db.py index 0776e53..5244b0e 100644 --- a/pssecret/redis_db.py +++ b/pssecret/redis_db.py @@ -1,6 +1,11 @@ # noinspection PyUnresolvedReferences,PyProtectedMember +from typing import Annotated + +from fastapi import Depends from redis import asyncio as aioredis -from pssecret.settings import settings +from pssecret.settings import Settings, get_settings -redis = aioredis.from_url(str(settings.redis_url)) + +def get_redis(settings: Annotated[Settings, Depends(get_settings)]) -> aioredis.Redis: + return aioredis.from_url(str(settings.redis_url)) diff --git a/pssecret/settings.py b/pssecret/settings.py index 8c8265f..e761e9a 100644 --- a/pssecret/settings.py +++ b/pssecret/settings.py @@ -6,4 +6,5 @@ class Settings(BaseSettings): redis_url: RedisDsn = RedisDsn("redis://localhost") -settings = Settings() +def get_settings() -> Settings: + return Settings() diff --git a/pssecret/utils.py b/pssecret/utils.py index f9fd015..6d5a324 100644 --- a/pssecret/utils.py +++ b/pssecret/utils.py @@ -1,11 +1,22 @@ from uuid import uuid4 -from pssecret.redis_db import redis +from redis.asyncio import Redis + +from pssecret.models import Secret -async def get_new_key() -> str: +async def get_new_key(redis: Redis) -> str: + """Returns free Redis key""" while True: new_key = str(uuid4()) if not await redis.exists(new_key): return new_key + + +async def save_secret(data: Secret, redis: Redis) -> str: + """Save passed data, returns retrieval key""" + new_key = await get_new_key(redis) + await redis.setex(new_key, 60 * 60 * 24, data.data) + + return new_key diff --git a/pyproject.toml b/pyproject.toml index 62eba9b..13e27c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,20 @@ hiredis = { version = "3.1.0", optional = true } hiredis = ["hiredis"] [tool.poetry.group.dev.dependencies] +pytest = ">=8.3.4,<9" +pytest-asyncio = "*" +polyfactory = ">=2.18,<3" + +[tool.poetry.group.dev-extra.dependencies] +ipython = "^8.31.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.basedpyright] +reportUnknownMemberType = "none" +reportUnusedCallResult = "none" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..932d863 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from collections.abc import AsyncGenerator + +import pytest +from fastapi.testclient import TestClient +from pydantic_settings import SettingsConfigDict +from redis import asyncio as aioredis + +from pssecret.main import app +from pssecret.settings import Settings, get_settings + + +class TestSettings(Settings): + model_config = SettingsConfigDict(env_file=".test.env") + + +@pytest.fixture +def settings() -> Settings: + return TestSettings() + + +@pytest.fixture() +async def redis_server(settings: Settings) -> AsyncGenerator[aioredis.Redis]: + redis = await aioredis.from_url(str(settings.redis_url)) + yield redis + await redis.flushdb() + + +def get_test_settings() -> Settings: + return TestSettings() + + +@pytest.fixture(scope="session") +def client() -> TestClient: + client_ = TestClient(app) + + app.dependency_overrides[get_settings] = get_test_settings + + return client_ diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..ffd86ad --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,6 @@ +from polyfactory.factories.pydantic_factory import ModelFactory + +from pssecret.models import Secret + + +class SecretFactory(ModelFactory[Secret]): ... diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..22303b2 --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient + +from tests.factories import SecretFactory + + +def test_store_secret_returns_key(client: TestClient): + response = client.post("/secret", json=dict(SecretFactory().build())) + + assert response.json()["key"] + + +def test_stored_secret_could_be_retrieved(client: TestClient): + secret = SecretFactory().build() + response = client.post("/secret", json=dict(secret)) + + retrieval_key: str = response.json()["key"] + + response = client.get(f"/secret/{retrieval_key}") + + assert response.json()["data"] == secret.data + + +def test_stored_secret_could_be_retrieved_only_once(client: TestClient): + secret = SecretFactory().build() + response = client.post("/secret", json=dict(secret)) + + retrieval_key: str = response.json()["key"] + + client.get(f"/secret/{retrieval_key}") + response = client.get(f"/secret/{retrieval_key}") + + assert response.status_code == 404 + assert secret.data not in response.text diff --git a/tests/integration/test_utils.py b/tests/integration/test_utils.py new file mode 100644 index 0000000..de84fdf --- /dev/null +++ b/tests/integration/test_utils.py @@ -0,0 +1,35 @@ +from unittest.mock import patch + +from redis.asyncio import Redis + +from pssecret.utils import get_new_key, save_secret + +from ..factories import SecretFactory + + +async def test_get_new_key_returns_key(redis_server: Redis) -> None: + assert isinstance(await get_new_key(redis_server), str) + + +async def test_get_new_key_returns_free_key(redis_server: Redis) -> None: + new_key = await get_new_key(redis_server) + res = not await redis_server.exists(new_key) + assert res + + +@patch("pssecret.utils.uuid4", side_effect=("used", "free")) +async def test_get_new_key_skips_used_keys(_, redis_server: Redis) -> None: + await redis_server.set("used", "") + + assert await get_new_key(redis_server) == "free" + + +async def test_save_secret_data(redis_server: Redis) -> None: + secret = SecretFactory.build() + + key = await save_secret(secret, redis_server) + + redis_data: bytes | None = await redis_server.get(key) + + assert redis_data is not None + assert redis_data.decode() == secret.data