From da8284ae6463ae8b81e17d57b376a7b19cf28915 Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 5 Sep 2026 13:06:04 +0300 Subject: [PATCH 1/5] implemented rate limit and set up redis container --- configs/.env.example | 10 +++++-- docker/compose-dev.yaml | 27 +++++++++++++++-- main.py | 2 ++ makefile | 4 +-- poetry.lock | 22 +++++++++++++- pyproject.toml | 5 +++- src/cache/__init__.py | 1 + src/cache/rate_limit.py | 29 +++++++++++++++++++ src/cache/redis_client.py | 23 +++++++++++++++ src/models/configs_read/env.py | 4 +++ src/web/protected_routes/auth_routes.py | 22 ++++++++++---- .../protected_user_action_routes.py | 10 +++---- 12 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 src/cache/__init__.py create mode 100644 src/cache/rate_limit.py create mode 100644 src/cache/redis_client.py diff --git a/configs/.env.example b/configs/.env.example index 1682bc3..8c56c27 100644 --- a/configs/.env.example +++ b/configs/.env.example @@ -1,10 +1,14 @@ SECRET_KEY = "change_me" ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 15 -REFRESH_TOKEN_EXPIRE_DAYS= 45 +ACCESS_TOKEN_EXPIRE_MINUTES = 15 #int +REFRESH_TOKEN_EXPIRE_DAYS= 45 #int DB_USER="change_me" DB_PASSWORD="change_me" DB_POSTGRESS="change_me" DB_HOST="change_me" -DB_PORT="change_me" \ No newline at end of file +DB_PORT="change_me" + +REDIS_PASSWORD="change_me" +REDIS_PORT=change_me #int +REDIS_HOST="change_me" \ No newline at end of file diff --git a/docker/compose-dev.yaml b/docker/compose-dev.yaml index 8e78096..0ed58b9 100644 --- a/docker/compose-dev.yaml +++ b/docker/compose-dev.yaml @@ -12,7 +12,7 @@ services: init: true #Manage processes and reap zombies ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications environment: - - DB_HOST=psql + - DB_HOST=psql #rewrite DB_HOST var to communicate inside the docker network volumes: - type: bind source: ../src @@ -37,6 +37,8 @@ services: depends_on: psql: condition: service_healthy + redis: + condition: service_healthy ports: - "80:8000" entrypoint: ["./entrypoint.sh", "--dev"] @@ -72,6 +74,8 @@ services: depends_on: psql: condition: service_healthy + redis: + condition: service_healthy ports: - "80:8000" @@ -98,8 +102,25 @@ services: timeout: 5s retries: 5 ports: - - "5432:5432" - + - "${DB_PORT}:5432" + + redis: + image: redis:latest + profiles: ["prod", "dev", "redis"] + container_name: redis + init: true + ipc: private + ports: + - '${REDIS_PORT}:6379' + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"] + healthcheck: + test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"] + interval: 1s + timeout: 3s + retries: 5 + restart: unless-stopped + + networks: backend: name: "${BACKEND_NETWORK:-backend_network}" \ No newline at end of file diff --git a/main.py b/main.py index 95d74d7..3ab7fd7 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ from pathlib import Path # import uvicorn from fastapi import FastAPI +from src.cache.redis_client import redis_client from src.database.users.crud import Seed from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware from src.web.protected_routes.auth_routes import router as protected_router @@ -17,6 +18,7 @@ async def lifespan(app: FastAPI): create_dirs() await create_first_user() yield + await redis_client.close() app=FastAPI(root_path="/", lifespan=lifespan) diff --git a/makefile b/makefile index 9df9efe..a7cd129 100644 --- a/makefile +++ b/makefile @@ -17,11 +17,11 @@ help: ## .PHONY: run run: ## Run dev local application - docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload + docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload .PHONY: down down: ## Down dev local db - docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db down + docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis down .PHONY: run-dev run-dev: ## Run dev application diff --git a/poetry.lock b/poetry.lock index 783393d..1363908 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2012,6 +2012,26 @@ files = [ {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] +[[package]] +name = "redis" +version = "8.1.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb"}, + {file = "redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25"}, +] + +[package.extras] +circuit-breaker = ["pybreaker (>=1.4.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.13.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] +otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"] +xxhash = ["xxhash (>=3.6.0,<3.7.0)"] + [[package]] name = "requests" version = "2.34.2" @@ -2400,4 +2420,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "f507694d0ef7cad070da5cc13f689e93240a272af43b3dd64b1a9ec2b3bbbea4" +content-hash = "30e907fcd3da88ca5c08edcb5cf49e0011f4cc702052583dd4496ca89b40e26b" diff --git a/pyproject.toml b/pyproject.toml index f1d4834..1475da4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "aiofiles (>=25.1.0,<26.0.0)", "asyncpg (>=0.31.0,<0.32.0)", "psycopg2-binary (>=2.9.12,<3.0.0)", + "redis (>=8.1.0,<9.0.0)", ] [tool.poetry.group.dev.dependencies] @@ -54,4 +55,6 @@ omit = [ [tool.coverage.report] exclude_lines = [ "pragma: no cover", -] \ No newline at end of file +] +[tool.ruff.lint] +ignore=["B008"] \ No newline at end of file diff --git a/src/cache/__init__.py b/src/cache/__init__.py new file mode 100644 index 0000000..96ab0fe --- /dev/null +++ b/src/cache/__init__.py @@ -0,0 +1 @@ +#redis dir \ No newline at end of file diff --git a/src/cache/rate_limit.py b/src/cache/rate_limit.py new file mode 100644 index 0000000..4e59d5b --- /dev/null +++ b/src/cache/rate_limit.py @@ -0,0 +1,29 @@ +from fastapi import Request + +from src.cache.redis_client import redis_client +from src.errors.http_errors.errors import Errors + + +class RateLimit: + def __init__(self) -> None: + self.errors=Errors() + + async def rate_limit(self, ip:str)->None: + + key=f"action attempt {ip}" + attempts = await redis_client.incrby(key) + + if attempts == 1: + await redis_client.expire(key, 60) + + if attempts>5: + raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60) + + async def check_rate_limit(self, request: Request) -> None: + client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown') + await self.rate_limit(client_ip) + + +rate_limiter=RateLimit() + + diff --git a/src/cache/redis_client.py b/src/cache/redis_client.py new file mode 100644 index 0000000..88ab515 --- /dev/null +++ b/src/cache/redis_client.py @@ -0,0 +1,23 @@ +import redis.asyncio as redis + +from src.models.configs_read.env import env_settings + + +class RedisClient: + def __init__(self) -> None: + self.client = redis.Redis( + host=env_settings.REDIS_HOST, + port=env_settings.REDIS_PORT, + password=env_settings.REDIS_PASSWORD, + decode_responses=True, + max_connections=10, + ) + + def __getattr__(self, name): + return getattr(self.client, name) + + async def close(self) -> None: + await self.client.aclose() + +redis_client = RedisClient() + diff --git a/src/models/configs_read/env.py b/src/models/configs_read/env.py index cf57372..30e7be3 100644 --- a/src/models/configs_read/env.py +++ b/src/models/configs_read/env.py @@ -16,6 +16,10 @@ class Env(Base): DB_HOST:str DB_PORT:str + REDIS_PASSWORD:str + REDIS_PORT:int + REDIS_HOST:str + model_config=SettingsConfigDict(env_file="configs/.env", extra=None) env_settings=Env() # type: ignore[call-arg] \ No newline at end of file diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index 5f19f7d..503c6b6 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from src.cache.rate_limit import rate_limiter from src.models.configs_read.env import env_settings from src.models.pydantic_models.model import UserOut from src.service.auth.auth import CurrentUserService, auth_service @@ -12,14 +13,19 @@ oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/pro def require_permissions(*permissions: str): #permissions check dependency async def checker( token: str = Depends(oauth2_schema), - auth: CurrentUserService = Depends(auth_service), #noqa: B008 + auth: CurrentUserService = Depends(auth_service), ) -> UserOut: return UserOut.model_validate(await auth.get_current_user(token, *permissions)) return checker @router.post("/token") -async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008 +async def get_access_token(request: Request, + response:Response, + auth:CurrentUserService=Depends(auth_service), + form_data:OAuth2PasswordRequestForm=Depends(), + _:None = Depends(rate_limiter.check_rate_limit) + )->dict: access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) @@ -35,7 +41,10 @@ async def get_access_token(request: Request,response:Response,auth:CurrentUserSe @router.post("/refresh") -async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008 +async def get_refresh_token(request:Request, + response:Response, + refresh_token: str = Cookie(), + auth:CurrentUserService=Depends(auth_service))->dict: access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request) @@ -51,10 +60,13 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st return {"access_token":access_token, "token_type": "bearer"} @router.get("/logout") -async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(require_permissions()))->bool: # noqa: B008 +async def logout(response:Response, + refresh_token: str = Cookie(), + auth:CurrentUserService=Depends(auth_service), + current_user:UserOut=Depends(require_permissions()))->bool: response.delete_cookie("refresh_token") return await auth.logout(refresh_token) @router.get("") -async def protected(current_user:UserOut=Depends(require_permissions()))->dict: # noqa: B008 +async def protected(current_user:UserOut=Depends(require_permissions()))->dict: return {"protected router": "Hello, this is a protected router"} diff --git a/src/web/protected_routes/protected_user_action_routes.py b/src/web/protected_routes/protected_user_action_routes.py index d25d3f9..d19928b 100644 --- a/src/web/protected_routes/protected_user_action_routes.py +++ b/src/web/protected_routes/protected_user_action_routes.py @@ -7,22 +7,22 @@ from src.web.protected_routes.auth_routes import require_permissions router=APIRouter(prefix="/user") @router.get("/get_by_email") -async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: # noqa: B008 +async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: return await crud.get_user_by_email(email) @router.post("/create_user") -async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 +async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: return await crud.create_user(data) @router.post("/delete_user_soft") -async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 +async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: return await crud.delete_user_soft(email) @router.post("/delete_user_hard") -async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 +async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: return await crud.delete_user_hard(email, current_user) @router.patch("/patch_user") -async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 +async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: return await crud.update_user(email, data) \ No newline at end of file From c4a6a88d05443b8b7c7ea2d427ad1bd6c439922f Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 5 Sep 2026 20:26:42 +0300 Subject: [PATCH 2/5] revoke access_tokens --- docker/compose-dev.yaml | 2 +- src/service/auth/auth.py | 33 +++++++++++++++++++------ src/service/auth/jwt.py | 5 +++- src/web/protected_routes/auth_routes.py | 4 ++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docker/compose-dev.yaml b/docker/compose-dev.yaml index 0ed58b9..2f23a7d 100644 --- a/docker/compose-dev.yaml +++ b/docker/compose-dev.yaml @@ -1,4 +1,4 @@ -name: excel-project +name: disexcel services: backend-dev: diff --git a/src/service/auth/auth.py b/src/service/auth/auth.py index 746e454..9a81b12 100644 --- a/src/service/auth/auth.py +++ b/src/service/auth/auth.py @@ -4,6 +4,7 @@ from uuid import UUID from fastapi import Request +from src.cache.redis_client import redis_client from src.database.auth.refresh_tokens import JwtCrudActions from src.database.users.crud import UsersCrudActions from src.errors.http_errors.errors import Errors @@ -52,8 +53,13 @@ class CurrentUserService: async def get_current_user(self, token:str, *permissions: str)->UserOut: payload= await self.jwt_service.jwt_decode(token) + sub=payload.get("sub") - + jti_access=payload.get("jti") + + if jti_access and await redis_client.get(f"revoked_access_token:{jti_access}"): + raise self.error.credentials_error(detail="Token has been revoked") + try: sub=UUID(sub) except (ValueError, TypeError) as e: @@ -167,21 +173,34 @@ class CurrentUserService: - async def logout(self, refresh_token:str)->bool: + async def logout(self, refresh_token:str, access_token:str)->bool: '''decode current refresh token''' - payload=await self.jwt_service.jwt_decode(refresh_token) + payload_refresh=await self.jwt_service.jwt_decode(refresh_token) - if (jti:=payload.get("jti")) is None: - raise self.error.credentials_error(detail="Jwt token is incorrect") + '''decode current access token''' + payload_access=await self.jwt_service.jwt_decode(access_token) + + + if (jti_refresh:=payload_refresh.get("jti")) is None: + raise self.error.credentials_error(detail="Jwt refresh token is incorrect") + + if (jti_access:=payload_access.get("jti")) is None or (exp_access:=payload_access.get("exp")) is None: + raise self.error.credentials_error(detail="Jwt access token is incorrect") try: - jti=UUID(jti) + jti_refresh=UUID(jti_refresh) + jti_access=UUID(jti_access) except (ValueError, TypeError, AttributeError) as e: raise self.error.credentials_error(detail="Jwt token is incorrect") from e + if jti_access and exp_access: + exp_datetime = datetime.fromtimestamp(exp_access, tz=UTC) + remaining_ttl = max(int((exp_datetime - datetime.now(UTC)).total_seconds()), 1) + await redis_client.set(f"revoked_access_token:{jti_access}", "1", ex=remaining_ttl) #revoke tokens and set them to redis until their exp ends + '''logout by assigning revoked flag''' - if await self.jwt_db_actions.logout(jti): + if await self.jwt_db_actions.logout(jti_refresh): return True else: raise self.error.not_found_error(detail="Refresh Token Not Found") diff --git a/src/service/auth/jwt.py b/src/service/auth/jwt.py index 25368c8..a8dff5e 100644 --- a/src/service/auth/jwt.py +++ b/src/service/auth/jwt.py @@ -35,14 +35,17 @@ class JwtService: if not (data.get("sub")) or data.get("sub") == "": raise self.error.credentials_error(detail="Jwt token is incorrect") + async def create_access_token(self, data:dict)->str: user_info=data.copy() + jti=str(uuid4()) await self._validate_sub(user_info) user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES), - "token_type":"access"}) + "token_type":"access", + "jti":jti}) return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM) diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index 503c6b6..44d74c4 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -62,10 +62,12 @@ async def get_refresh_token(request:Request, @router.get("/logout") async def logout(response:Response, refresh_token: str = Cookie(), + access_token: str = Depends(oauth2_schema), auth:CurrentUserService=Depends(auth_service), current_user:UserOut=Depends(require_permissions()))->bool: + response.delete_cookie("refresh_token") - return await auth.logout(refresh_token) + return await auth.logout(refresh_token, access_token) @router.get("") async def protected(current_user:UserOut=Depends(require_permissions()))->dict: From 9fd44c0ad9199187590cbc8001e2141f3251fef6 Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 5 Sep 2026 21:55:31 +0300 Subject: [PATCH 3/5] fix rate-limit valid login bug and fix sessions of redis and psql in tests --- src/cache/rate_limit.py | 3 +-- src/models/database_models/model.py | 7 ++++++- src/web/protected_routes/auth_routes.py | 11 ++++++++--- tests/conftest.py | 18 +++++++++++++++++- tests/e2e/test_auth.py | 2 ++ tests/integrated/conftest.py | 19 ++++++++++--------- tests/integrated/test_auth.py | 11 ++++++----- tests/unit/conftest.py | 9 --------- 8 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/cache/rate_limit.py b/src/cache/rate_limit.py index 4e59d5b..6ed4fa2 100644 --- a/src/cache/rate_limit.py +++ b/src/cache/rate_limit.py @@ -19,8 +19,7 @@ class RateLimit: if attempts>5: raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60) - async def check_rate_limit(self, request: Request) -> None: - client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown') + async def check_rate_limit(self, client_ip:str) -> None: await self.rate_limit(client_ip) diff --git a/src/models/database_models/model.py b/src/models/database_models/model.py index ad27682..fe7523f 100644 --- a/src/models/database_models/model.py +++ b/src/models/database_models/model.py @@ -17,7 +17,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from src.models.configs_read.env import env_settings -engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}") +engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}", + pool_size=20, # сколько соединений держать открытыми постоянно + max_overflow=10, # сколько доп. соединений можно создать при пиковой нагрузке + pool_timeout=30, # сколько ждать свободное соединение, прежде чем упасть с ошибкой + pool_pre_ping=True, # проверять соединение перед использованием (ловит "протухшие" соединения) + ) '''remember as a boilerplate, or just cp/pst''' class Model(DeclarativeBase): diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index 44d74c4..f35d12b 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Cookie, Depends, Request, Response +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from src.cache.rate_limit import rate_limiter @@ -24,10 +24,15 @@ async def get_access_token(request: Request, response:Response, auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends(), - _:None = Depends(rate_limiter.check_rate_limit) )->dict: - access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) + client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown') + + try: + access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) + except HTTPException: + await rate_limiter.rate_limit(client_ip) + raise response.set_cookie( key="refresh_token", diff --git a/tests/conftest.py b/tests/conftest.py index 663e14f..95c1382 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ import pytest_asyncio +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from src.models.configs_read.env import env_settings from src.service.auth.jwt import HashService, JwtService +from src.service.users_crud.users_crud import CrudService @pytest_asyncio.fixture @@ -11,4 +14,17 @@ async def jwt_service()->JwtService: @pytest_asyncio.fixture async def hash_service()->HashService: hash_service=HashService() - return hash_service \ No newline at end of file + return hash_service + +@pytest_asyncio.fixture +async def crud_service(): + test_engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}", + pool_size=20, + max_overflow=10, + pool_timeout=30, + pool_pre_ping=True + ) + crud_service = CrudService() + crud_service.crud_db_actions.Session = async_sessionmaker(bind=test_engine) + yield crud_service + await test_engine.dispose() \ No newline at end of file diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index 597680d..45618f9 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -12,8 +12,10 @@ class TestAuth: response = await requests_async.post(f"{target_url}/protected/token") response.raise_for_status() + assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 401 + assert exc_info.value.response.status_code != 429 async def test_get_refresh_token_positive(self, target_url:str)->None: diff --git a/tests/integrated/conftest.py b/tests/integrated/conftest.py index 635927b..82172f4 100644 --- a/tests/integrated/conftest.py +++ b/tests/integrated/conftest.py @@ -1,22 +1,23 @@ import pytest_asyncio from fastapi import Request +from src.cache.redis_client import RedisClient from src.service.auth.auth import CurrentUserService -from src.service.users_crud.users_crud import CrudService @pytest_asyncio.fixture -async def current_user_service()->CurrentUserService: - current_user_service=CurrentUserService() - return current_user_service +async def current_user_service(monkeypatch): + + test_redis = RedisClient() + monkeypatch.setattr("src.service.auth.auth.redis_client", test_redis) + + service = CurrentUserService() + + yield service + await test_redis.aclose() @pytest_asyncio.fixture async def requests(mocker): fake_request = mocker.MagicMock(spec=Request) fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"} return fake_request - -@pytest_asyncio.fixture -async def crud_service()->CrudService: - crud_service=CrudService() - return crud_service \ No newline at end of file diff --git a/tests/integrated/test_auth.py b/tests/integrated/test_auth.py index 7424d1e..16ac2a3 100644 --- a/tests/integrated/test_auth.py +++ b/tests/integrated/test_auth.py @@ -118,7 +118,8 @@ class TestAuth: with allure.step("create fake refresh token"): - token=await jwt_service.create_refresh_token({"sub":str(uuid4())}) + refresh_token=await jwt_service.create_refresh_token({"sub":str(uuid4())}) + access_token=await jwt_service.create_access_token({"sub":str(uuid4)}) with allure.step("patching db call functions"): @@ -126,7 +127,7 @@ class TestAuth: with allure.step("test logout with fake data"): - status=await current_user_service.logout(token[0]) + status=await current_user_service.logout(refresh_token[0], access_token) assert status is True @pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[ @@ -148,11 +149,11 @@ class TestAuth: with allure.step("create fake refresh token"): - token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}) - + refresh_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}) + access_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(minutes=30)}) with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info: - await current_user_service.logout(token) + await current_user_service.logout(refresh_token, access_token) if expected_exception is HTTPException: assert exc_info.value.status_code==expected_status diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3ee8c0e..e69de29 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,9 +0,0 @@ -import pytest_asyncio - -from src.service.users_crud.users_crud import CrudService - - -@pytest_asyncio.fixture -async def crud_service()->CrudService: - crud_service=CrudService() - return crud_service \ No newline at end of file From 5cdcd342dcff52dcd425a689cff36335299da28e Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 5 Sep 2026 22:31:54 +0300 Subject: [PATCH 4/5] add tests for the redis --- pyproject.toml | 4 +++- src/cache/rate_limit.py | 6 +----- tests/e2e/test_auth.py | 42 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1475da4..841d794 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,9 @@ omit = [ "*/database/*", "*/errors/*", "__init__.py", - "*/docker/*" + "*/docker/*", + "*/rate_limit.py", + "*/logger.py" ] [tool.coverage.report] diff --git a/src/cache/rate_limit.py b/src/cache/rate_limit.py index 6ed4fa2..b508532 100644 --- a/src/cache/rate_limit.py +++ b/src/cache/rate_limit.py @@ -1,5 +1,3 @@ -from fastapi import Request - from src.cache.redis_client import redis_client from src.errors.http_errors.errors import Errors @@ -23,6 +21,4 @@ class RateLimit: await self.rate_limit(client_ip) -rate_limiter=RateLimit() - - +rate_limiter=RateLimit() \ No newline at end of file diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index 45618f9..39484ea 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -4,7 +4,7 @@ import requests_async from httpx import HTTPStatusError -class TestAuth: +class TestPermissions: async def test_get_access_token_positive(self, target_url:str)->None: @@ -15,7 +15,6 @@ class TestAuth: assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 401 - assert exc_info.value.response.status_code != 429 async def test_get_refresh_token_positive(self, target_url:str)->None: @@ -26,7 +25,7 @@ class TestAuth: assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 401 - async def test_get_root_positive(self, target_url:str)->None: + async def test_get_root_unauthorized(self, target_url:str)->None: with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: @@ -45,4 +44,41 @@ class TestAuth: response.raise_for_status() assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 401 + + +class TestRedis: + + @pytest.mark.parametrize("wrong_user_data, expected_status",[ + pytest.param({"username":"Wrong_user", "password":"Wrong_password"},429,id="Wrong_user_creds") + ]) + async def test_rate_limit_positive(self, wrong_user_data:dict, target_url:str, expected_status:int): + + with allure.step("logging with invalid creds"): + + for i in range(5): + with pytest.raises(HTTPStatusError) as exc_info: + response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data) + response.raise_for_status() + assert exc_info.value.response.status_code == 401, f"Attempt {i+1} should be 401" + + with allure.step("verify rate_limit works"),pytest.raises(HTTPStatusError) as exc_info: + response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data) + response.raise_for_status() + assert exc_info.value.response.status_code == expected_status + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_get_logout_revoke_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("logout"): + + response = await session.get(f"{target_url}/protected/logout") + response.raise_for_status() + + with allure.step("verify token is revoked"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.get(f"{target_url}/protected") + response.raise_for_status() + assert exc_info.value.response.status_code==429 \ No newline at end of file From ba5e90c5167a96575a92b932fdf3d775cbeceefe Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sat, 5 Sep 2026 23:02:42 +0300 Subject: [PATCH 5/5] fix rate_limit tests and add prod_mode to env --- configs/.env.example | 4 +++- src/models/configs_read/env.py | 2 ++ src/web/protected_routes/auth_routes.py | 4 ++-- tests/e2e/test_auth.py | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/configs/.env.example b/configs/.env.example index 8c56c27..e20e27c 100644 --- a/configs/.env.example +++ b/configs/.env.example @@ -11,4 +11,6 @@ DB_PORT="change_me" REDIS_PASSWORD="change_me" REDIS_PORT=change_me #int -REDIS_HOST="change_me" \ No newline at end of file +REDIS_HOST="change_me" + +PROD_MODE=bool \ No newline at end of file diff --git a/src/models/configs_read/env.py b/src/models/configs_read/env.py index 30e7be3..332dabb 100644 --- a/src/models/configs_read/env.py +++ b/src/models/configs_read/env.py @@ -20,6 +20,8 @@ class Env(Base): REDIS_PORT:int REDIS_HOST:str + PROD_MODE:bool + model_config=SettingsConfigDict(env_file="configs/.env", extra=None) env_settings=Env() # type: ignore[call-arg] \ No newline at end of file diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index f35d12b..4f95c5c 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -38,7 +38,7 @@ async def get_access_token(request: Request, key="refresh_token", value=refresh_token, httponly=True, - secure=True, + secure=env_settings.PROD_MODE, samesite="strict", max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 ) @@ -57,7 +57,7 @@ async def get_refresh_token(request:Request, key="refresh_token", value=refresh_token, httponly=True, - secure=True, + secure=env_settings.PROD_MODE, samesite="strict", max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 ) diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index 39484ea..a39dd00 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -68,7 +68,7 @@ class TestRedis: @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) - async def test_get_logout_revoke_positive(self,test_user_fixture, target_url:str)->None: + async def test_logout_revokes_access_token(self,test_user_fixture, target_url:str)->None: session=test_user_fixture[0] @@ -80,5 +80,5 @@ class TestRedis: with allure.step("verify token is revoked"), pytest.raises(HTTPStatusError) as exc_info: response = await session.get(f"{target_url}/protected") response.raise_for_status() - assert exc_info.value.response.status_code==429 + assert exc_info.value.response.status_code==401 \ No newline at end of file