diff --git a/.gitignore b/.gitignore index 06bc64a..034edd1 100644 --- a/.gitignore +++ b/.gitignore @@ -173,5 +173,3 @@ notebook.ipynb dotenv/ .ruff_cache .tokens.txt -fixtures -data diff --git a/.isort.cfg b/.isort.cfg index fe85a01..f7d7a8f 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = beanie,email_validator,fastapi,fastapi_cache,fastapi_jwt,fastapi_pagination,httpx,jinja2,jose,mongomock_motor,pwdlib,pydantic,pydantic_settings,pymongo,pyotp,pytest,pytest_asyncio,redis,slugify,starlette,typer,uvicorn,yaml +known_third_party = beanie,email_validator,fastapi,fastapi_cache,fastapi_jwt,fastapi_pagination,httpx,jinja2,jose,mongomock_motor,pwdlib,pydantic,pydantic_settings,pymongo,pyotp,pytest,pytest_asyncio,slugify,starlette,typer,uvicorn,yaml diff --git a/data/params.yml b/data/params.yml new file mode 100644 index 0000000..62d2f9a --- /dev/null +++ b/data/params.yml @@ -0,0 +1,35 @@ +params: + - name: "Spécialités" + data: + - "Allergologie ou Immunologie" + - "Anesthésiologie" + - "Cardiologie" + - "Chirurgie" + - "Dermatologie" + - "Endocrinologie" + - "Gastro-entérologie" + - "Gériatrie" + - "Gynécologie" + - "Hématologie" + - "Hépatologie" + - "Infectiologie" + - "Néonatologie" + - "Odontologie" + - "Oncologie" + - "Obstétrique" + - "Ophtalmologie" + - "Orthopédie" + - "Oto-rhino-laryngologie" + - "Pédiatrie" + - "Pneumologie" + - "Psychiatrie" + - "Radiologie" + - "Radiothérapie" + - "Rhumatologie" + - "Urologie" + - "Andrologie" + + - name: "Sexe" + data: + - "Masculin" + - "Féminin" diff --git a/src/__init__.py b/src/__init__.py index 0bd80bf..a08237c 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -6,22 +6,20 @@ from fastapi import FastAPI, HTTPException from fastapi.encoders import jsonable_encoder from fastapi.responses import RedirectResponse -from fastapi_cache import FastAPICache -from fastapi_cache.backends.redis import RedisBackend from fastapi_pagination import add_pagination from httpx import AsyncClient -from redis import asyncio as aioredis from slugify import slugify from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse from src.common.helpers.appdesc import load_app_description, load_permissions +from src.common.helpers.caching import init_redis_cache from src.common.helpers.error_codes import AppErrorCode from src.common.helpers.exceptions import setup_exception_handlers from src.config import settings, shutdown_db, startup_db -from src.models import Role, User, Params -from src.routers import auth_router, perm_router, role_router, user_router, param_router +from src.models import Params, Role, User +from src.routers import auth_router, param_router, perm_router, role_router, user_router from src.services import roles, users from src.shared import blacklist_token @@ -44,8 +42,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: blacklist_token.init_blacklist_token_file() - redis = aioredis.from_url(settings.CACHE_DB_URL) - FastAPICache.init(RedisBackend(redis), prefix=f"__{settings.APP_NAME.lower()}") + await init_redis_cache(app_name=BASE_URL, cache_db_url=settings.CACHE_DB_URL) yield await shutdown_db(app=app) diff --git a/src/common b/src/common index 342a6d1..d5981c3 160000 --- a/src/common +++ b/src/common @@ -1 +1 @@ -Subproject commit 342a6d1feced1839bd1051d67e52cc93f0857c96 +Subproject commit d5981c3e90f5a65dfff66946c32312f9f967d8eb diff --git a/src/config/settings.py b/src/config/settings.py index e1220fd..3da3ea7 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -14,14 +14,15 @@ class AuthBaseConfig(BaseSettings): APP_ACCESS_LOG: Optional[bool] = Field(default=True, alias="APP_ACCESS_LOG") APP_DEFAULT_PORT: Optional[PositiveInt] = Field(default=9077, alias="APP_DEFAULT_PORT") PASSWORD_MIN_LENGTH: Optional[PositiveInt] = Field(default=3, alias="PASSWORD_MIN_LENGTH") - ENABLE_OTP_CODE: Optional[bool] = Field(default=True, alias="ENABLE_OTP_CODE") + ENABLE_OTP_CODE: Optional[bool] = Field(..., alias="ENABLE_OTP_CODE") OTP_CODE_DIGIT_LENGTH: Optional[PositiveInt] = Field(default=4, alias="OTP_CODE_DIGIT_LENGTH") - REGISTER_WITH_EMAIL: Optional[bool] = Field(default=False, alias="REGISTER_WITH_EMAIL") - LIST_ROLES_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field( - default=False, alias="LIST_ROLES_ENDPOINT_SECURITY_ENABLED" - ) + REGISTER_WITH_EMAIL: Optional[bool] = Field(..., alias="REGISTER_WITH_EMAIL") + LIST_ROLES_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field(..., alias="LIST_ROLES_ENDPOINT_SECURITY_ENABLED") REGISTER_USER_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field( - default=False, alias="REGISTER_USER_ENDPOINT_SECURITY_ENABLED" + ..., alias="REGISTER_USER_ENDPOINT_SECURITY_ENABLED" + ) + LIST_PARAMETERS_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field( + ..., alias="LIST_PARAMETERS_ENDPOINT_SECURITY_ENABLED" ) LIST_PARAMETERS_ENDPOINT_SECURITY_ENABLED: Optional[bool] = Field( default=False, alias="LIST_PARAMETERS_ENDPOINT_SECURITY_ENABLED" diff --git a/src/middleware/auth.py b/src/middleware/auth.py index 4ff2fc2..83b1ebe 100644 --- a/src/middleware/auth.py +++ b/src/middleware/auth.py @@ -13,12 +13,12 @@ from pwdlib.hashers.bcrypt import BcryptHasher from slugify import slugify +from src.common.helpers.caching import custom_key_builder as cache_key_builder from src.common.helpers.exceptions import CustomHTTException from src.config import jwt_settings, settings from src.services.roles import get_one_role from src.shared import blacklist_token from src.shared.error_codes import AuthErrorCode -from src.shared.utils import custom_key_builder logging.basicConfig(format="%(message)s", level=logging.INFO) @@ -82,7 +82,6 @@ def decode_access_token(cls, token: str) -> dict: return result @classmethod - @cache(expire=settings.EXPIRE_CACHE, key_builder=custom_key_builder) # noqa async def verify_access_token(cls, token: str) -> bool: """ Verifies the validity of an access token by checking the cache and token properties. @@ -119,6 +118,7 @@ async def verify_access_token(cls, token: str) -> bool: ) from err @classmethod + @cache(expire=settings.EXPIRE_CACHE, key_builder=cache_key_builder(settings.APP_NAME + "check-permissions")) # noqa async def check_permissions(cls, token: str, required_permissions: Set[str] = ()) -> bool: """ Checks if the token has the required permissions. diff --git a/src/routers/auth.py b/src/routers/auth.py index 683e407..a3d3512 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -13,10 +13,13 @@ LoginUser, PhonenumberModel, RequestChangePassword, + SendEmailMessage, + SendSmsMessage, UserBaseSchema, VerifyOTP, ) from src.services import auth +from src.shared import mail_service, sms_service from src.shared.utils import custom_key_builder auth_router = APIRouter(prefix="", tags=["AUTH"], redirect_slashes=False) @@ -121,3 +124,22 @@ async def check_user_attributes( key: str = Query(...), value: str = Query(...), in_attributes: Optional[bool] = Query(default=False) ): return await auth.check_user_attribute(key=key, value=value, in_attributes=in_attributes) + + +auth_router.tags = ["SEND MESSAGE"] +auth_router.prefix = "/send" + + +@auth_router.post("-sms", summary="Send a message", status_code=status.HTTP_200_OK, include_in_schema=False) +async def send_sms(background: BackgroundTasks, payload: SendSmsMessage = Body(...)): + phone = payload.phone_number.replace("+", "") + await sms_service.send_sms(background, recipient=phone, message=payload.message) + return {"message": "SMS sent successfully."} + + +@auth_router.post("-email", summary="Send a e-mail", status_code=status.HTTP_200_OK, include_in_schema=False) +async def send_email(background: BackgroundTasks, payload: SendEmailMessage = Body(...)): + mail_service.send_email_background( + background, receiver_email=payload.recipients, subject=payload.subject, body=payload.message + ) + return {"message": "E-mail sent successfully."} diff --git a/src/routers/roles.py b/src/routers/roles.py index 03a1494..d50b975 100644 --- a/src/routers/roles.py +++ b/src/routers/roles.py @@ -7,6 +7,7 @@ from src.config import enable_endpoint, settings from src.middleware import AuthorizedHTTPBearer, CheckPermissionsHandler +from src.common.helpers.caching import delete_custom_key from src.models import Role from src.schemas import RoleModel from src.services import roles @@ -15,6 +16,13 @@ role_router = APIRouter(prefix="/roles", tags=["ROLES"], redirect_slashes=False) +@role_router.post( + "/_create", + response_model=Role, + summary="Create role (internal)", + status_code=status.HTTP_201_CREATED, + include_in_schema=False, +) @role_router.post( "", dependencies=( @@ -100,7 +108,7 @@ async def delete_role(id: PydanticObjectId): if bool(enable_endpoint.SHOW_MEMBERS_IN_ROLE_ENDPOINT): @role_router.get( - "/{id}/members", + "/{name}/members", dependencies=[ Depends(AuthorizedHTTPBearer), Depends(CheckPermissionsHandler(required_permissions={"auth:can-display-role"})), @@ -110,12 +118,19 @@ async def delete_role(id: PydanticObjectId): status_code=status.HTTP_200_OK, ) async def get_role_members( - id: PydanticObjectId, + name: str, sorting: Optional[SortEnum] = Query(SortEnum.DESC, description="Order by creation date: 'asc' or 'desc"), ): - return await roles.get_roles_members(role_id=PydanticObjectId(id), sorting=sorting) + return await roles.get_users_for_role(name=name, sorting=sorting) +@role_router.patch( + "/{id}/_assign-permissions", + response_model=Role, + summary="Assign permissions to role (internal)", + status_code=status.HTTP_202_ACCEPTED, + include_in_schema=False, +) @role_router.patch( "/{id}/assign-permissions", dependencies=[ @@ -127,4 +142,6 @@ async def get_role_members( status_code=status.HTTP_200_OK, ) async def manage_permission_to_role(id: PydanticObjectId, payload: Set[str] = Body(...)): - return await roles.assign_permissions_to_role(role_id=PydanticObjectId(id), permission_codes=payload) + result = await roles.assign_permissions_to_role(role_id=PydanticObjectId(id), permission_codes=payload) + await delete_custom_key(settings.APP_NAME + "check-permissions") + return result diff --git a/src/routers/users.py b/src/routers/users.py index 5e134bd..b24c5cb 100644 --- a/src/routers/users.py +++ b/src/routers/users.py @@ -118,6 +118,14 @@ async def listing_users( summary="Get single user", status_code=status.HTTP_200_OK, ) +@user_router.get( + "/_i_{id}", + response_model=UserOut, + response_model_exclude={"password", "is_primary", "attributes.otp_secret", "attributes.otp_created_at"}, + summary="Get single user (internal)", + status_code=status.HTTP_200_OK, + include_in_schema=False, +) async def get_user(id: PydanticObjectId): return await users.get_one_user(user_id=PydanticObjectId(id)) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index a6bae05..2e00962 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -2,7 +2,7 @@ from .response import ResponseModelData from .roles import RoleModel from .params import ParamsModel -from .mixins import FilterParams +from .mixins import FilterParams, SendEmailMessage, SendSmsMessage from .users import CreateUser, PhonenumberModel, UpdateUser, UserBaseSchema __all__ = [ @@ -20,4 +20,6 @@ "RoleModel", "ParamsModel", "FilterParams", + "SendEmailMessage", + "SendSmsMessage", ] diff --git a/src/schemas/mixins.py b/src/schemas/mixins.py index f5e51d2..ab445ad 100644 --- a/src/schemas/mixins.py +++ b/src/schemas/mixins.py @@ -1,7 +1,7 @@ from typing import Optional from fastapi import Query -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr class FilterParams(BaseModel): @@ -11,3 +11,14 @@ class FilterParams(BaseModel): def get_filter_params(type: Optional[str] = Query(None), name: Optional[str] = Query(None)) -> FilterParams: return FilterParams(type=type, name=name) + + +class SendSmsMessage(BaseModel): + message: str + phone_number: str + + +class SendEmailMessage(BaseModel): + subject: Optional[str] = None + message: str + recipients: EmailStr diff --git a/src/services/auth.py b/src/services/auth.py index bb182fe..daa6001 100644 --- a/src/services/auth.py +++ b/src/services/auth.py @@ -78,26 +78,10 @@ async def login(payload: LoginUser) -> JSONResponse: async def logout(request: Request) -> JSONResponse: - - authorization = request.headers.get("Authorization").split()[1] - await blacklist_token.add_blacklist_token(authorization) - - decode_token = CustomAccessBearer.decode_access_token(authorization) - - data = decode_token["subject"] - user_data = { - "id": data["id"], - "email": data["email"], - "fullname": data["fullname"], - "role": data["role"], - "is_active": data["is_active"], - } - response_data = { - "access_token": CustomAccessBearer.access_token(data=user_data, user_id=decode_token["jti"]), - "referesh_token": CustomAccessBearer.refresh_token(data=user_data, user_id=decode_token["jti"]), - } - - return JSONResponse(status_code=status.HTTP_200_OK, content=jsonable_encoder(response_data)) + authorization = request.headers.get("Authorization") + token = authorization.split()[1] + await blacklist_token.add_blacklist_token(token) + return JSONResponse(content={"message": "Logout successfully !"}, status_code=status.HTTP_200_OK) async def change_password(user_id: PydanticObjectId, change_password: ChangePassword): diff --git a/src/services/roles.py b/src/services/roles.py index 8aef2c7..c63a47a 100644 --- a/src/services/roles.py +++ b/src/services/roles.py @@ -93,12 +93,17 @@ async def update_role(role_id: PydanticObjectId, update_role: RoleModel) -> Role return result -async def get_roles_members(role_id: PydanticObjectId, sorting: Optional[SortEnum] = SortEnum.DESC): - role = await get_one_role(role_id=role_id) +async def get_users_for_role(name: str, sorting: Optional[SortEnum] = SortEnum.DESC): + if (role := await Role.find_one({"slug": slugify(name)})) is None: + raise CustomHTTException( + code_error=RoleErrorCode.ROLE_NOT_FOUND, + message_error=f"Role with '{name}' not found.", + status_code=status.HTTP_400_BAD_REQUEST, + ) sorted = DESCENDING if sorting == SortEnum.DESC else ASCENDING users = await User.find({"role": PydanticObjectId(role.id)}, sort=[("created_at", sorted)]).to_list() users_list = [{**user.model_dump(by_alias=True, exclude={"password", "is_primary"})} for user in users] - result = paginate(users_list, additional_data={"role_info": role}) + result = paginate(users_list) return result diff --git a/src/shared/scripts/roles.py b/src/shared/scripts/roles.py index b4deac8..53e2acc 100644 --- a/src/shared/scripts/roles.py +++ b/src/shared/scripts/roles.py @@ -1,44 +1,44 @@ import typer -import httpx -from src.config import settings -app = typer.Typer(pretty_exceptions_enable=False) - -BASE_URL = f"http://0.0.0.0:{settings.APP_DEFAULT_PORT}" - - -def make_request(method: str, url: str, access_token: str, json=None, data=None): - headers = {"Authorization": f"Bearer {access_token}", "accept": "application/json"} - with httpx.Client(timeout=30) as client: - response = getattr(client, method)(url, headers=headers, json=json, data=data) +from .utils import BASE_URL, make_request - try: - response.raise_for_status() - except httpx.HTTPStatusError as exc: - typer.echo(f"API error: {exc.response.text}", err=True) - raise typer.Exit(code=1) from exc - - return response.json() +app = typer.Typer(pretty_exceptions_enable=False) @app.command(help="Create a role and assign permissions") def create_role_and_assign_permissions(): + """ + Command to create a role and optionally assign permissions to it. + + Prompts the user for a role name and optional permissions, then creates the role + and assigns the specified permissions. + + Raises: + typer.Exit: If the API request fails. + """ role_name = typer.prompt(text="Role name", type=str) - access_token = typer.prompt(text="Access token", hide_input=True, type=str) permissions = typer.prompt(text="Permissions to assign (optional) (comma-separated)", type=str, default="") # Create role - create_url = f"{BASE_URL}/roles" - role_data = make_request("post", create_url, access_token, json={"name": role_name}) - role_id = role_data["_id"] - typer.echo(f"Role '{role_name}' with ID {role_id} created successfully.") - - # Assign permissions - if permissions: - permissions_list = [perm.strip() for perm in permissions.split(",")] - assign_url = f"{BASE_URL}/roles/{role_id}/assign-permissions" - make_request("patch", assign_url, access_token, json=permissions_list) - typer.echo(f"Permissions assigned to role '{role_name}' successfully.") + create_url = f"{BASE_URL}/roles/_create" + ret = make_request(method="post", url=create_url, json={"name": role_name}) + + if ret.is_success: + role_data = ret.json() + role_id = role_data.get("_id") + typer.echo(f"Role '{role_name}' with ID {role_id} created successfully.") + + # Assign permissions + if permissions: + permissions_list = [perm.strip() for perm in permissions.split(",")] + assign_url = f"{BASE_URL}/roles/{role_id}/_assign-permissions" + response = make_request(method="patch", url=assign_url, json=permissions_list) + if response.is_success: + typer.echo(f"Permissions assigned to role '{role_name}' successfully.") + else: + typer.echo(f"Failed to assign permissions to role '{role_name}'.") + else: + typer.echo(f"Failed to create role '{role_name}'.") if __name__ == "__main__": diff --git a/src/shared/scripts/users.py b/src/shared/scripts/users.py index 7121292..5802886 100644 --- a/src/shared/scripts/users.py +++ b/src/shared/scripts/users.py @@ -1,8 +1,7 @@ -import httpx import typer from email_validator import EmailNotValidError, validate_email -from src.config import settings +from .utils import BASE_URL, make_request app = typer.Typer(pretty_exceptions_enable=False) @@ -37,17 +36,8 @@ def create_user(): "password": password, } - api_url = f"http://0.0.0.0:{settings.APP_DEFAULT_PORT}/users/add" - - with httpx.Client(timeout=30) as client: - response = client.post(api_url, json=payload) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as exc: - typer.echo(f"API error: {exc.response.text}", err=True) - raise typer.Exit(code=1) from exc - + api_url = f"{BASE_URL}/users/add" + response = make_request(method="post", url=api_url, json=payload) if response.is_success: typer.echo(f"User with '{email}' created successfully.") diff --git a/src/shared/scripts/utils.py b/src/shared/scripts/utils.py new file mode 100644 index 0000000..3fc85c6 --- /dev/null +++ b/src/shared/scripts/utils.py @@ -0,0 +1,44 @@ +from typing import Optional, Union + +import httpx +import typer + +from src.config import settings + +BASE_URL = f"http://127.0.0.1:{settings.APP_DEFAULT_PORT}" + + +def make_request( + method: str, + url: str, + access_token: Optional[str] = None, + json: Optional[Union[str, dict, list]] = None, + data: Optional[Union[str, dict, list]] = None, +): + """ + Make an HTTP request using the specified method. + + Args: + method (str): The HTTP method to use (e.g., 'get', 'post', 'patch'). + url (str): The URL to send the request to. + access_token (str, optional): The access token for authorization. Defaults to None. + json (dict, optional): The JSON payload to send with the request. Defaults to None. + data (dict, optional): The form data to send with the request. Defaults to None. + + Returns: + dict: The JSON response from the API. + + Raises: + typer.Exit: If the API request fails. + """ + headers = {"Authorization": f"Bearer {access_token}", "accept": "application/json"} + with httpx.Client(timeout=30) as client: + response = getattr(client, method)(url, headers=headers, json=json, data=data) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + typer.echo(f"API error: {exc.response.text}", err=True) + raise typer.Exit(code=1) from exc + + return response diff --git a/src/shared/send_sms.py b/src/shared/send_sms.py index 065ae1a..066c61e 100644 --- a/src/shared/send_sms.py +++ b/src/shared/send_sms.py @@ -47,10 +47,12 @@ async def __call__(self, recipient: str, message: str, *args, **kwargs) -> Dict[ async def _send_sms_task(self, recipient: str, message: str): try: await self.__call__(recipient, message) - except SMSException as e: - _log.error(f"Failed to send SMS to {recipient}: {str(e)}") - except Exception as e: - _log.error(f"Unexpected error while sending SMS to {recipient}: {str(e)}") + except SMSException as exc: + _log.error(f"Failed to send SMS to {recipient}: {str(exc)}") + raise exc from exc + except Exception as err: + _log.error(f"Unexpected error while sending SMS to {recipient}: {str(err)}") + raise err from err async def send_sms(self, background_task: BackgroundTasks, recipient: str, message: str): background_task.add_task(self._send_sms_task, recipient, message) diff --git a/src/shared/utils.py b/src/shared/utils.py index 213d6cc..78fd997 100644 --- a/src/shared/utils.py +++ b/src/shared/utils.py @@ -1,5 +1,6 @@ import logging import os +from pathlib import Path from enum import StrEnum from secrets import compare_digest from typing import Callable, Optional, TypeVar @@ -42,15 +43,20 @@ def custom_key_builder( url_path = "" if request is not None: + # Récupérer le token d'autorisation depuis les en-têtes de la requête if (token := request.headers.get("Authorization")) is not None: token_value = token.split()[1] if len(token.split()) > 1 else token else: + # Récupérer le premier paramètre de requête si le token n'est pas dans les en-têtes token_value = next(iter(request.query_params.values()), "") + # Convertir les paramètres de requête en chaîne de caractères triée query_params_str = repr(sorted(request.query_params.items())) + # Récupérer le chemin de l'URL de la requête url_path = request.url.path - result = ":".join([token_value, query_params_str, url_path]) + # Construire la clé personnalisée + result = f"{token_value}:{query_params_str}:{url_path}" return result @@ -83,40 +89,34 @@ def verify_opt_code(cls, secret_otp: str, verify_otp: str) -> bool: class TokenBlacklistHandler: - def __init__(self): - self._token_file = os.getenv("BLACKLIST_TOKEN_FILE") + self._token_file = Path(os.getenv("BLACKLIST_TOKEN_FILE", ".token.txt")) if not self._token_file: - raise ValueError("Blacklist file does not exist !") - else: - self.init_blacklist_token_file() + raise ValueError("Blacklist file does not exist!") + self.init_blacklist_token_file() def init_blacklist_token_file(self) -> bool: - if not os.path.exists(self._token_file): - try: - open(file=self._token_file, mode="a").close() - logger.info("--> Initialising the token blacklist file !") - except IOError as e: - raise IOError(f"Error when initialising the token blacklist file: {e}") from e - logger.info("--> Token blacklist file already exist !") + try: + self._token_file.touch(exist_ok=True) + except IOError as e: + raise IOError(f"Error when initializing the token blacklist file: {e}") from e return True async def add_blacklist_token(self, token: str) -> bool: try: - with open(file=self._token_file, mode="a", encoding="utf-8") as file: + with self._token_file.open(mode="a", encoding="utf-8") as file: file.write(f"{token},") - logger.info("--> Adding token to blacklist file !") + logger.info("--> Adding token to blacklist file!") except IOError as e: raise IOError(f"Error when adding token to blacklist: {e}") from e return True async def is_token_blacklisted(self, token: str) -> bool: try: - with open(file=self._token_file, encoding="utf-8") as file: + with self._token_file.open(encoding="utf-8") as file: content = file.read() tokens = content.rstrip(",").split(",") - logger.info("--> The token already exists in the blacklist !") except IOError as e: - raise IOError(f"Error verifying token in black list: {e}") from e + raise IOError(f"Error verifying token in blacklist: {e}") from e return any(compare_digest(value, token) for value in tokens) diff --git a/tests/.test.env b/tests/.test.env index 327c529..1d55abc 100644 --- a/tests/.test.env +++ b/tests/.test.env @@ -33,6 +33,7 @@ DEFAULT_USER_ROLE="patients,medecins" # USER MODEL NAME USER_MODEL_NAME=test_auth_coll ROLE_MODEL_NAME=test_roles_coll +PARAM_MODEL_NAME=test_params_coll # MONGODB URI CONFIG MONGO_DB=test diff --git a/tests/conftest.py b/tests/conftest.py index b480c6f..1c54abe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,7 +72,7 @@ def mock_custom_access_bearer(): @pytest.fixture -def mock_authorized_http_bearer(): +def mock_verify_access_token(): with mock.patch("src.middleware.CustomAccessBearer.verify_access_token") as mock_verify: mock_verify.return_value = None yield mock_verify diff --git a/tests/middlewares/test_auth.py b/tests/middlewares/test_auth.py index 8450568..0ab6412 100644 --- a/tests/middlewares/test_auth.py +++ b/tests/middlewares/test_auth.py @@ -132,20 +132,6 @@ async def test_check_permissions_success( result = await self.custom_access_token.check_permissions("fake_access_token", {"perm-1"}) assert result is True - @pytest.mark.asyncio - @mock.patch("src.services.auth.get_one_role") - @mock.patch("src.middleware.auth.CustomAccessBearer.decode_access_token") - async def test_check_permissions_insufficient( - self, mock_decode_access_token, mock_get_one_role, fake_user_data, fake_role_data - ): - mock_decode_access_token.return_value = {"subject": fake_user_data} - mock_get_one_role.return_value = fake_role_data - - with pytest.raises(CustomHTTException) as exc_info: - await self.custom_access_token.check_permissions("fake_access_token", {"perm-3"}) - assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN - assert exc_info.value.code_error == AuthErrorCode.AUTH_INSUFFICIENT_PERMISSION - class TestAuthorizeHTTPBearer: diff --git a/tests/routers/test_roles_api.py b/tests/routers/test_roles_api.py index ffc2d3c..1eeb066 100644 --- a/tests/routers/test_roles_api.py +++ b/tests/routers/test_roles_api.py @@ -4,18 +4,16 @@ @pytest.mark.asyncio -async def test_create_roles_unauthorized(http_client_api, mock_authorized_http_bearer, fake_role_data): - response = await http_client_api.post( - "/roles", json=fake_role_data, headers={"Authorization": "Bearer valid_token"} - ) +async def test_create_roles_unauthorized(http_client_api, fake_role_data): + response = await http_client_api.post("/roles", json=fake_role_data) - assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text - assert response.json() == {"code_error": "auth/invalid-access-token", "message_error": "Not enough segments"} + assert response.status_code == status.HTTP_403_FORBIDDEN, response.text + assert response.json() == {"code_error": "auth/no-authenticated", "message_error": "Not authenticated"} @pytest.mark.asyncio async def test_create_roles_already_exists( - http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler, fake_role_collection, fake_role_data + http_client_api, mock_verify_access_token, mock_check_permissions_handler, fake_role_collection, fake_role_data ): fake_role_data.update({"name": fake_role_collection.name}) response = await http_client_api.post( @@ -29,14 +27,14 @@ async def test_create_roles_already_exists( "message_error": f"This role '{fake_role_data['name']}' already exists.", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() @pytest.mark.asyncio async def test_create_roles_success( - http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler, fake_role_data + http_client_api, mock_verify_access_token, mock_check_permissions_handler, fake_role_data ): response = await http_client_api.post( "/roles", json=fake_role_data, headers={"Authorization": "Bearer valid_token"} @@ -45,28 +43,25 @@ async def test_create_roles_success( assert response.status_code == status.HTTP_201_CREATED, response.text assert fake_role_data["name"] == response.json()["name"] - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() @pytest.mark.asyncio -async def test_listing_roles_success(http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler): +async def test_listing_roles_success(http_client_api): response = await http_client_api.get("/roles", headers={"Authorization": "Bearer valid_token"}) assert response.status_code == status.HTTP_200_OK, response.text assert "items" in response.json() assert response.json()["total"] == 0 - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") - mock_check_permissions_handler.assert_called_once() - +@pytest.mark.asyncio async def test_read_role_success( http_client_api, fake_role_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): role_id = fake_role_collection.id @@ -75,14 +70,15 @@ async def test_read_role_success( assert response.json()["_id"] == str(role_id) assert response.json()["slug"] == fake_role_collection.slug - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_read_role_not_found( http_client_api, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): response = await http_client_api.get( @@ -94,16 +90,17 @@ async def test_read_role_not_found( "message_error": "Role with '66e85363aa07cb1e95d3e3d0' not found.", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_role_success( http_client_api, fake_role_collection, fake_role_data, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): role_id = fake_role_collection.id @@ -116,16 +113,17 @@ async def test_update_role_success( assert response.json()["_id"] == str(role_id) assert response.json()["slug"] == slugify(fake_role_data["name"]) - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_role_not_found( http_client_api, fake_role_collection, fake_role_data, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): fake_role_data.update({"name": "Manager"}) @@ -138,15 +136,16 @@ async def test_update_role_not_found( "message_error": "Role with '66e85363aa07cb1e95d3e3d0' not found.", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_user_bad_request( http_client_api, fake_role_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): role_id = fake_role_collection.id @@ -158,33 +157,35 @@ async def test_update_user_bad_request( "message_error": "[{'field': 'body', 'message': 'Field required'}]", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_get_role_members_succes( http_client_api, fake_role_collection, fake_user_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): - role_id = fake_role_collection.id + role = fake_role_collection.name - response = await http_client_api.get(f"/roles/{role_id}/members", headers={"Authorization": "Bearer valid_token"}) + response = await http_client_api.get(f"/roles/{role}/members", headers={"Authorization": "Bearer valid_token"}) assert response.status_code == status.HTTP_200_OK, response.text assert response.json()["total"] >= 1 assert response.json()["items"][0]["role"] == str(fake_user_collection.role) - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_delete_role_not_found( http_client_api, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): response = await http_client_api.delete( @@ -192,15 +193,16 @@ async def test_delete_role_not_found( ) assert response.status_code == status.HTTP_204_NO_CONTENT, response.text - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_delete_role_success( http_client_api, fake_role_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): role_id = fake_role_collection.id @@ -208,6 +210,6 @@ async def test_delete_role_success( response = await http_client_api.delete(f"/roles/{role_id}", headers={"Authorization": "Bearer valid_token"}) assert response.status_code == status.HTTP_204_NO_CONTENT, response.text - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() diff --git a/tests/routers/test_users_api.py b/tests/routers/test_users_api.py index 259365d..2f4131e 100644 --- a/tests/routers/test_users_api.py +++ b/tests/routers/test_users_api.py @@ -14,13 +14,11 @@ async def test_ping_api(http_client_api): @pytest.mark.asyncio -async def test_create_users_unauthorized(http_client_api, mock_authorized_http_bearer, fake_user_data): - response = await http_client_api.post( - "/users", json=fake_user_data, headers={"Authorization": "Bearer valid_token"} - ) +async def test_create_users_unauthorized(http_client_api, fake_user_data): + response = await http_client_api.post("/users", json=fake_user_data) - assert response.status_code == status.HTTP_401_UNAUTHORIZED, response.text - assert response.json() == {"code_error": "auth/invalid-access-token", "message_error": "Not enough segments"} + assert response.status_code == status.HTTP_403_FORBIDDEN, response.text + assert response.json() == {"code_error": "auth/no-authenticated", "message_error": "Not authenticated"} @pytest.mark.skip @@ -37,14 +35,14 @@ async def test_create_users_forbidden(http_client_api, mock_check_permissions_ha @pytest.mark.asyncio async def test_create_users_no_authenticated( - http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler, fake_user_data + http_client_api, mock_verify_access_token, mock_check_permissions_handler, fake_user_data ): response = await http_client_api.post("/users", json=fake_user_data) assert response.status_code == status.HTTP_403_FORBIDDEN, response.text assert response.json() == {"code_error": "auth/no-authenticated", "message_error": "Not authenticated"} - mock_authorized_http_bearer.assert_not_called() + mock_verify_access_token.assert_not_called() mock_check_permissions_handler.assert_not_called() @@ -63,7 +61,7 @@ async def test_add_users_failed(http_client_api, fake_user_data): @pytest.mark.asyncio async def test_create_users_already_exists( - http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler, fake_user_collection, fake_user_data + http_client_api, mock_verify_access_token, mock_check_permissions_handler, fake_user_collection, fake_user_data ): fake_user_data.update({"email": fake_user_collection.email}) @@ -78,14 +76,14 @@ async def test_create_users_already_exists( "message_error": f"User with email '{fake_user_data['email']}' already exists", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() @pytest.mark.asyncio async def test_create_users_success( - http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler, fake_user_data + http_client_api, mock_verify_access_token, mock_check_permissions_handler, fake_user_data ): response = await http_client_api.post( "/users", json=fake_user_data, headers={"Authorization": "Bearer valid_token"} @@ -94,27 +92,27 @@ async def test_create_users_success( assert response.status_code == status.HTTP_201_CREATED, response.text assert fake_user_data["email"] == response.json()["email"] - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() @pytest.mark.asyncio -async def test_listing_users_success(http_client_api, mock_authorized_http_bearer, mock_check_permissions_handler): +async def test_listing_users_success(http_client_api, mock_verify_access_token, mock_check_permissions_handler): response = await http_client_api.get("/users", headers={"Authorization": "Bearer valid_token"}) assert response.status_code == status.HTTP_200_OK, response.text assert "items" in response.json() - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() async def test_listing_users_with_query_success( http_client_api, fake_user_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): search_email = fake_user_collection.email @@ -125,15 +123,16 @@ async def test_listing_users_with_query_success( assert response.json()["items"][0]["email"] == search_email assert response.json()["total"] >= 1, "Le total doit-être supérieur ou égal à 1" - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_read_user_success( http_client_api, fake_user_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): user_id = fake_user_collection.id @@ -142,14 +141,15 @@ async def test_read_user_success( assert response.json()["_id"] == str(user_id) assert response.json()["email"] == fake_user_collection.email - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_read_user_not_found( http_client_api, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): response = await http_client_api.get( @@ -161,16 +161,17 @@ async def test_read_user_not_found( "message_error": "User with '66e85363aa07cb1e95d3e3d0' not found.", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_user_success( http_client_api, fake_user_collection, fake_user_data, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): user_id = fake_user_collection.id @@ -183,16 +184,17 @@ async def test_update_user_success( assert response.json()["_id"] == str(user_id) assert response.json()["fullname"] == fake_user_data["fullname"] - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_user_not_found( http_client_api, fake_user_collection, fake_user_data, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): fake_user_data.update({"fullname": "Adele"}) @@ -205,15 +207,16 @@ async def test_update_user_not_found( "message_error": "User with '66e85363aa07cb1e95d3e3d0' not found.", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_update_user_bad_request( http_client_api, fake_user_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): user_id = fake_user_collection.id @@ -225,15 +228,16 @@ async def test_update_user_bad_request( "message_error": "[{'field': 'body', 'message': 'Field required'}]", } - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once() +@pytest.mark.asyncio async def test_delete_user_success( http_client_api, fake_user_collection, - mock_authorized_http_bearer, + mock_verify_access_token, mock_check_permissions_handler, ): user_id = fake_user_collection.id @@ -241,6 +245,6 @@ async def test_delete_user_success( response = await http_client_api.delete(f"/users/{user_id}", headers={"Authorization": "Bearer valid_token"}) assert response.status_code == status.HTTP_204_NO_CONTENT, response.text - mock_authorized_http_bearer.assert_called_once() - mock_authorized_http_bearer.assert_called_once_with("valid_token") + mock_verify_access_token.assert_called_once() + mock_verify_access_token.assert_called_once_with("valid_token") mock_check_permissions_handler.assert_called_once()