From 2382f0becef4ecdea0d4ad89f4b127ed60030777 Mon Sep 17 00:00:00 2001 From: icecheng Date: Fri, 18 Jul 2025 18:05:57 +0800 Subject: [PATCH 01/19] feat(role_management): add crud for role and permission --- apps/authentication/__init__.py | 0 apps/authentication/backend/__init__.py | 0 .../backend/annotation/__init__.py | 0 .../infra/permission/permission_handler.py | 76 +++++++++++++++++++ .../backend/infra/permission/role_handler.py | 73 ++++++++++++++++++ .../authentication/backend/models/__init__.py | 2 + .../backend/models/permission/__init__.py | 3 + .../backend/models/permission/constants.py | 29 ++++++- .../backend/models/permission/models.py | 37 +++++++++ .../backend/models/user/models.py | 3 +- .../services/permission/permission_service.py | 32 ++++++++ .../services/permission/role_service.py | 36 +++++++++ apps/authentication/common/__init__.py | 0 apps/authentication/local.env | 24 ++++++ .../webapi/bootstrap/application.py | 2 + .../webapi/providers/exception_handler.py | 8 ++ .../webapi/providers/permission_initialize.py | 37 +++++++++ apps/authentication/webapi/routes/__init__.py | 4 + .../webapi/routes/permission/__init__.py | 11 +++ .../routes/permission/create_permission.py | 36 +++++++++ .../routes/permission/query_permission.py | 50 ++++++++++++ .../routes/permission/update_permission.py | 38 ++++++++++ .../webapi/routes/role/__init__.py | 10 +++ .../webapi/routes/role/create_role.py | 40 ++++++++++ .../webapi/routes/role/query_role.py | 52 +++++++++++++ .../webapi/routes/role/update_role.py | 41 ++++++++++ .../webapi/routes/user/__init__.py | 0 .../webapi/routes/user/assign_role.py | 0 .../webapi/routes/user/remove_role.py | 0 apps/notification/.env.local | 1 + 30 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 apps/authentication/__init__.py create mode 100644 apps/authentication/backend/__init__.py create mode 100644 apps/authentication/backend/annotation/__init__.py create mode 100644 apps/authentication/backend/infra/permission/permission_handler.py create mode 100644 apps/authentication/backend/infra/permission/role_handler.py create mode 100644 apps/authentication/backend/models/permission/__init__.py create mode 100644 apps/authentication/backend/models/permission/models.py create mode 100644 apps/authentication/backend/services/permission/permission_service.py create mode 100644 apps/authentication/backend/services/permission/role_service.py create mode 100644 apps/authentication/common/__init__.py create mode 100644 apps/authentication/local.env create mode 100644 apps/authentication/webapi/providers/permission_initialize.py create mode 100644 apps/authentication/webapi/routes/permission/__init__.py create mode 100644 apps/authentication/webapi/routes/permission/create_permission.py create mode 100644 apps/authentication/webapi/routes/permission/query_permission.py create mode 100644 apps/authentication/webapi/routes/permission/update_permission.py create mode 100644 apps/authentication/webapi/routes/role/__init__.py create mode 100644 apps/authentication/webapi/routes/role/create_role.py create mode 100644 apps/authentication/webapi/routes/role/query_role.py create mode 100644 apps/authentication/webapi/routes/role/update_role.py create mode 100644 apps/authentication/webapi/routes/user/__init__.py create mode 100644 apps/authentication/webapi/routes/user/assign_role.py create mode 100644 apps/authentication/webapi/routes/user/remove_role.py create mode 100644 apps/notification/.env.local diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/backend/__init__.py b/apps/authentication/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/backend/annotation/__init__.py b/apps/authentication/backend/annotation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/backend/infra/permission/permission_handler.py b/apps/authentication/backend/infra/permission/permission_handler.py new file mode 100644 index 0000000..ea05694 --- /dev/null +++ b/apps/authentication/backend/infra/permission/permission_handler.py @@ -0,0 +1,76 @@ +from typing import Optional, List, Tuple + +from fastapi.exceptions import RequestValidationError + +from backend.models.permission.models import PermissionDoc +from beanie import PydanticObjectId +from datetime import datetime + +class PermissionHandler: + def __init__(self): + pass + + async def create_permission(self, permission_key: str, permission_name: str, + description: Optional[str] = None) -> Optional[PermissionDoc]: + """Create a new permission document""" + if not permission_key or not permission_name: + raise RequestValidationError("permission_key and permission_name are required.") + # if exists. + if await PermissionDoc.find_one( + {str(PermissionDoc.permission_key): permission_key}) or await PermissionDoc.find_one( + {str(PermissionDoc.permission_name): permission_name}): + raise RequestValidationError("permission has already been created.") + doc = PermissionDoc( + permission_key=permission_key, + permission_name=permission_name, + description=description, + created_at=datetime.now(), + updated_at=datetime.now() + ) + await doc.insert() + return doc + + async def update_permission(self, permission_id: PydanticObjectId, permission_key: Optional[str] = None, + permission_name: Optional[str] = None, description: Optional[str] = None) -> Optional[ + PermissionDoc]: + """Update an existing permission document by id, ensuring permission_key is unique""" + if not permission_id or not permission_key or not permission_name: + raise RequestValidationError("permission_id, permission_key and permission_name is required.") + doc = await PermissionDoc.get(permission_id) + if not doc: + raise RequestValidationError("Permission not found.") + # Check for uniqueness (exclude self) + if permission_key and permission_name: + conflict = await PermissionDoc.find_one({ + "$and": [ + {"_id": {"$ne": permission_id}}, + {"$or": [ + {str(PermissionDoc.permission_key): permission_key}, + {str(PermissionDoc.permission_name): permission_name} + ]} + ] + }) + if conflict: + raise RequestValidationError("Permission name or permission key already exists.") + doc.permission_key = permission_key + doc.updated_at = datetime.now() + await doc.save() + return doc + + async def query_permissions( + self, + permission_key: Optional[str] = None, + permission_name: Optional[str] = None, + skip: int = 0, + limit: int = 10 + ) -> Tuple[List[PermissionDoc], int]: + """Query permissions with pagination and fuzzy search""" + query = {} + if permission_key: + query[str(PermissionDoc.permission_key)] = {"$regex": permission_key, "$options": "i"} + if permission_name: + query[str(PermissionDoc.permission_name)] = {"$regex": permission_name, "$options": "i"} + cursor = PermissionDoc.find(query) + total = await cursor.count() + docs = await cursor.skip(skip).limit(limit).to_list() + return docs, total diff --git a/apps/authentication/backend/infra/permission/role_handler.py b/apps/authentication/backend/infra/permission/role_handler.py new file mode 100644 index 0000000..b18df20 --- /dev/null +++ b/apps/authentication/backend/infra/permission/role_handler.py @@ -0,0 +1,73 @@ +from typing import Optional, List, Tuple + +from fastapi.exceptions import RequestValidationError + +from backend.models.permission.models import RoleDoc +from beanie import PydanticObjectId +from datetime import datetime + + +class RoleHandler: + def __init__(self): + pass + + async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> Optional[RoleDoc]: + """Create a new role, ensuring role_key and role_name are unique and not empty""" + if not role_key or not role_name: + raise RequestValidationError("role_key and role_name are required.") + if await RoleDoc.find_one({str(RoleDoc.role_key): role_key}) or await RoleDoc.find_one( + {str(RoleDoc.role_name): role_name}): + raise RequestValidationError("role_key or role_name has already been created.") + doc = RoleDoc( + role_key=role_key, + role_name=role_name, + role_description=role_description, + permission_ids=[], + role_level=role_level, + created_at=datetime.now(), + updated_at=datetime.now() + ) + await doc.insert() + return doc + + async def update_role(self, role_id: PydanticObjectId, role_key: str, role_name: str, + role_description: Optional[str], role_level: int) -> Optional[ + RoleDoc]: + """Update an existing role, ensuring role_key and role_name are unique and not empty""" + if not role_id or not role_key or not role_name: + raise RequestValidationError("role_id, role_key and role_name are required.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("role not found.") + # Check for uniqueness (exclude self) + conflict = await RoleDoc.find_one({ + "$and": [ + {"_id": {"$ne": role_id}}, + {"$or": [ + {str(RoleDoc.role_key): role_key}, + {str(RoleDoc.role_name): role_name} + ]} + ] + }) + if conflict: + raise RequestValidationError("role_key or role_name already exists.") + doc.role_key = role_key + doc.role_name = role_name + doc.role_description = role_description + doc.role_level = role_level + doc.updated_at = datetime.now() + await doc.save() + return doc + + async def query_roles(self, role_key: Optional[str], role_name: Optional[str], skip: int = 0, limit: int = 10) -> \ + Tuple[List[RoleDoc], int]: + """Query roles with pagination and fuzzy search by role_key and role_name""" + query = {} + if role_key: + query[str(RoleDoc.role_key)] = {"$regex": role_key, "$options": "i"} + if role_name: + query[str(RoleDoc.role_name)] = {"$regex": role_name, "$options": "i"} + cursor = RoleDoc.find(query) + total = await cursor.count() + docs = await cursor.skip(skip).limit(limit).to_list() + return docs, total diff --git a/apps/authentication/backend/models/__init__.py b/apps/authentication/backend/models/__init__.py index b8bad29..4cf8898 100644 --- a/apps/authentication/backend/models/__init__.py +++ b/apps/authentication/backend/models/__init__.py @@ -1,6 +1,8 @@ from .user import user_models from .user_profile import profile_models +from .permission import permission_models backend_models = [] backend_models.extend(user_models) backend_models.extend(profile_models) +backend_models.extend(permission_models) diff --git a/apps/authentication/backend/models/permission/__init__.py b/apps/authentication/backend/models/permission/__init__.py new file mode 100644 index 0000000..4a14d68 --- /dev/null +++ b/apps/authentication/backend/models/permission/__init__.py @@ -0,0 +1,3 @@ +from .models import PermissionDoc, RoleDoc + +permission_models = [PermissionDoc, RoleDoc] diff --git a/apps/authentication/backend/models/permission/constants.py b/apps/authentication/backend/models/permission/constants.py index 89c6104..d957893 100644 --- a/apps/authentication/backend/models/permission/constants.py +++ b/apps/authentication/backend/models/permission/constants.py @@ -1,4 +1,31 @@ -from enum import IntEnum +from dataclasses import dataclass +from enum import IntEnum, Enum + + +@dataclass(frozen=True) # frozen=True +class DefaultRole: + role_name: str + role_key: str + role_description: str + role_level: int + + +# Default roles, which all tenants will have, cannot be modified. +class DefaultRoleEnum(Enum): + ADMIN = DefaultRole("Administrator", "admin", "Have all permissions", 0) + + +@dataclass(frozen=True) # frozen=True +class DefaultPermission: + permission_key: str + permission_name: str + permission_description: str + + +# Default permissions, which all tenants will have, cannot be modified. +class DefaultPermissionEnum(Enum): + CHANGE_ROLES = DefaultPermission("change:roles", "Change roles", "Add/Update/Delete roles") + CHANGE_PERMISSIONS = DefaultPermission("change:permissions", "Change permissions", "Add/Update/Remove permissions") class AdministrativeRole(IntEnum): diff --git a/apps/authentication/backend/models/permission/models.py b/apps/authentication/backend/models/permission/models.py new file mode 100644 index 0000000..423495d --- /dev/null +++ b/apps/authentication/backend/models/permission/models.py @@ -0,0 +1,37 @@ +from beanie import Document +from datetime import datetime +from typing import Optional + + +class PermissionDoc(Document): + permission_name: str + permission_key: str + description: Optional[str] = None # Description of the permission, optional + created_at: datetime = datetime.now() # Creation timestamp, auto-generated + updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_permission" + indexes = [ + "permission_key" + ] + + +class RoleDoc(Document): + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: list[str] + role_level: int + created_at: datetime = datetime.now() # Creation timestamp, auto-generated + updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_role" + indexes = [ + "role_level" + ] \ No newline at end of file diff --git a/apps/authentication/backend/models/user/models.py b/apps/authentication/backend/models/user/models.py index 826c2be..97ad23a 100644 --- a/apps/authentication/backend/models/user/models.py +++ b/apps/authentication/backend/models/user/models.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from beanie import Document @@ -18,6 +18,7 @@ class UserAccountDoc(Document): service_plan_id: Optional[str] properties: UserAccountProperty capabilities: Capability + user_role_ids: List[str] user_role: int = AdministrativeRole.NONE preferred_region: UserRegion = UserRegion.ZH_CN diff --git a/apps/authentication/backend/services/permission/permission_service.py b/apps/authentication/backend/services/permission/permission_service.py new file mode 100644 index 0000000..7de6838 --- /dev/null +++ b/apps/authentication/backend/services/permission/permission_service.py @@ -0,0 +1,32 @@ +from typing import Optional, Dict, Any + +from fastapi.exceptions import RequestValidationError + +from backend.infra.permission.permission_handler import PermissionHandler +from backend.models.permission.models import PermissionDoc +from beanie import PydanticObjectId + +class PermissionService: + def __init__(self): + self.permission_handler = PermissionHandler() + + async def create_permission(self, permission_key: str, permission_name: str, description: Optional[str] = None) -> PermissionDoc: + """Create a new permission document""" + return await self.permission_handler.create_permission(permission_key, permission_name, description) + + async def update_permission(self, permission_id: str, permission_key: Optional[str] = None, permission_name: Optional[str] = None, description: Optional[str] = None) -> PermissionDoc: + """Update an existing permission document by id""" + return await self.permission_handler.update_permission(PydanticObjectId(permission_id), permission_key, permission_name, description) + + async def query_permissions(self, permission_key: Optional[str] = None, permission_name: Optional[str] = None, page: int = 1, page_size: int = 10) -> Dict[str, Any]: + """Query permissions with pagination and fuzzy search""" + if page < 1 or page_size < 1: + raise RequestValidationError("page and page_size must be positive integers.") + skip = (page - 1) * page_size + docs, total = await self.permission_handler.query_permissions(permission_key, permission_name, skip, page_size) + return { + "items": [doc.dict() for doc in docs], + "total": total, + "page": page, + "page_size": page_size + } \ No newline at end of file diff --git a/apps/authentication/backend/services/permission/role_service.py b/apps/authentication/backend/services/permission/role_service.py new file mode 100644 index 0000000..8aba63e --- /dev/null +++ b/apps/authentication/backend/services/permission/role_service.py @@ -0,0 +1,36 @@ +from typing import Optional, Dict, Any + +from fastapi.exceptions import RequestValidationError + +from backend.infra.permission.role_handler import RoleHandler +from backend.models.permission.models import RoleDoc +from beanie import PydanticObjectId + +class RoleService: + def __init__(self): + self.role_handler = RoleHandler() + + async def create_role(self, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc: + """Create a new role, ensuring role_key and role_name are unique and not empty""" + + doc = await self.role_handler.create_role(role_key, role_name, role_description, role_level) + return doc + + async def update_role(self, role_id: str, role_key: str, role_name: str, role_description: Optional[str], role_level: int) -> RoleDoc: + """Update an existing role, ensuring role_key and role_name are unique and not empty""" + + doc = await self.role_handler.update_role(PydanticObjectId(role_id), role_key, role_name, role_description, role_level) + return doc + + async def query_roles(self, role_key: Optional[str], role_name: Optional[str], page: int = 1, page_size: int = 10) -> Dict[str, Any]: + """Query roles with pagination and fuzzy search by role_key and role_name""" + if page < 1 or page_size < 1: + raise RequestValidationError("page and page_size must be positive integers.") + skip = (page - 1) * page_size + docs, total = await self.role_handler.query_roles(role_key, role_name, skip, page_size) + return { + "items": [doc.dict() for doc in docs], + "total": total, + "page": page, + "page_size": page_size + } \ No newline at end of file diff --git a/apps/authentication/common/__init__.py b/apps/authentication/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/local.env b/apps/authentication/local.env new file mode 100644 index 0000000..23ac8e5 --- /dev/null +++ b/apps/authentication/local.env @@ -0,0 +1,24 @@ +APP_NAME=authentication +export SERVICE_API_ACCESS_HOST=0.0.0.0 +export SERVICE_API_ACCESS_PORT=8004 +export CONTAINER_APP_ROOT=/app +export LOG_BASE_PATH=$CONTAINER_APP_ROOT/log/$APP_NAME +export BACKEND_LOG_FILE_NAME=$APP_NAME +export APPLICATION_ACTIVITY_LOG=$APP_NAME-activity +export MONGODB_NAME=freeleaps2 +export MONGODB_PORT=27017 +export JWT_SECRET_KEY=ea84edf152976b2fcec12b78aa8e45bc26a5cf0ef61bf16f5c317ae33b3fd8b0 +GIT_REPO_ROOT=/mnt/freeleaps/freeleaps-service-hub +CODEBASE_ROOT=/mnt/freeleaps/freeleaps-service-hub/apps/authentication +SITE_DEPLOY_FOLDER=/mnt/freeleaps/freeleaps-service-hub/sites/authentication/deploy +#!/bin/bash +export VENV_DIR=venv_t +export VENV_ACTIVATE=venv_t/bin/activate +export DOCKER_HOME=/var/lib/docker +export DOCKER_APP_HOME=$DOCKER_HOME/app +export DOCKER_BACKEND_HOME=$DOCKER_APP_HOME/$APP_NAME +export DOCKER_BACKEND_LOG_HOME=$DOCKER_BACKEND_HOME/log +export MONGODB_URI=mongodb://localhost:27017/ +export FREELEAPS_ENV=local +export LOG_BASE_PATH=${CODEBASE_ROOT}/log + diff --git a/apps/authentication/webapi/bootstrap/application.py b/apps/authentication/webapi/bootstrap/application.py index 3f6dfb3..d857e7f 100644 --- a/apps/authentication/webapi/bootstrap/application.py +++ b/apps/authentication/webapi/bootstrap/application.py @@ -11,6 +11,7 @@ from webapi.providers import metrics # from webapi.providers import scheduler from webapi.providers import exception_handler +from webapi.providers import permission_initialize from .freeleaps_app import FreeleapsApp from common.config.app_settings import app_settings @@ -23,6 +24,7 @@ def create_app() -> FastAPI: register(app, exception_handler) register(app, database) register(app, router) + register(app, permission_initialize) # register(app, scheduler) register(app, common) diff --git a/apps/authentication/webapi/providers/exception_handler.py b/apps/authentication/webapi/providers/exception_handler.py index 21117a5..fd8cf43 100644 --- a/apps/authentication/webapi/providers/exception_handler.py +++ b/apps/authentication/webapi/providers/exception_handler.py @@ -1,3 +1,4 @@ +from bson.errors import InvalidId from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError from starlette.requests import Request @@ -26,6 +27,12 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE content={"error": str(exc)}, ) +async def validation_error_exception_handler(request: Request, exc: InvalidId): + return JSONResponse( + status_code=HTTP_400_BAD_REQUEST, + content={"error": str(exc)}, + ) + async def exception_handler(request: Request, exc: Exception): return JSONResponse( status_code=HTTP_500_INTERNAL_SERVER_ERROR, @@ -36,4 +43,5 @@ async def exception_handler(request: Request, exc: Exception): def register(app: FastAPI): app.add_exception_handler(HTTPException, custom_http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) + app.add_exception_handler(InvalidId, exception_handler) app.add_exception_handler(Exception, exception_handler) diff --git a/apps/authentication/webapi/providers/permission_initialize.py b/apps/authentication/webapi/providers/permission_initialize.py new file mode 100644 index 0000000..3107e12 --- /dev/null +++ b/apps/authentication/webapi/providers/permission_initialize.py @@ -0,0 +1,37 @@ +import logging + +from backend.models.permission import PermissionDoc, RoleDoc +from backend.models.permission.constants import DefaultPermissionEnum, DefaultRoleEnum + + +def register(app): + # Configure logging for pymongo + logging.getLogger("init_admin_permission").setLevel(logging.INFO) # Suppress DEBUG logs + + @app.on_event("startup") + async def init_admin_permission(): + # Initialize permissions if not exist + default_permission_ids = [] + for default_permission in [DefaultPermissionEnum.CHANGE_PERMISSIONS, DefaultPermissionEnum.CHANGE_ROLES]: + if not await PermissionDoc.find_one( + {str(PermissionDoc.permission_key): default_permission.value.permission_key}): + doc = await PermissionDoc( + permission_key=default_permission.value.permission_key, + permission_name=default_permission.value.permission_name, + description=default_permission.value.permission_description, + ).insert() + default_permission_ids.append(str(doc.id)) + logging.info(f"default permissions initialized {default_permission_ids}") + # Initialize roles if not exist + default_role_ids = [] + for default_role in [DefaultRoleEnum.ADMIN]: + if not await RoleDoc.find_one({str(RoleDoc.role_key): default_role.value.role_key}): + doc = await RoleDoc( + role_key=default_role.value.role_key, + role_name=default_role.value.role_name, + role_description=default_role.value.role_description, + permission_ids=default_permission_ids, + role_level=default_role.value.role_level, + ).insert() + default_role_ids.append(str(doc.id)) + logging.info(f"default roles initialized {default_role_ids}") diff --git a/apps/authentication/webapi/routes/__init__.py b/apps/authentication/webapi/routes/__init__.py index fce1be0..19a5ec5 100644 --- a/apps/authentication/webapi/routes/__init__.py +++ b/apps/authentication/webapi/routes/__init__.py @@ -2,9 +2,13 @@ from fastapi import APIRouter from .signin import router as signin_router from .tokens import router as token_router from .auth import router as auth_router +from .permission import router as permission_router +from .role import router as role_router api_router = APIRouter(prefix="/auth") api_router.include_router(signin_router, tags=["user"]) api_router.include_router(token_router, tags=["token"]) api_router.include_router(auth_router, tags=["auth"]) +api_router.include_router(permission_router, tags=["permission"]) +api_router.include_router(role_router, tags=["role"]) websocket_router = APIRouter() diff --git a/apps/authentication/webapi/routes/permission/__init__.py b/apps/authentication/webapi/routes/permission/__init__.py new file mode 100644 index 0000000..d79f009 --- /dev/null +++ b/apps/authentication/webapi/routes/permission/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from .create_permission import router as cp_router +from .query_permission import router as qp_router +from .update_permission import router as up_router + + +router = APIRouter() + +router.include_router(cp_router, prefix="/permission", tags=["permission"]) +router.include_router(qp_router, prefix="/permission", tags=["permission"]) +router.include_router(up_router, prefix="/permission", tags=["permission"]) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/permission/create_permission.py b/apps/authentication/webapi/routes/permission/create_permission.py new file mode 100644 index 0000000..19ee383 --- /dev/null +++ b/apps/authentication/webapi/routes/permission/create_permission.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + +class CreatePermissionRequest(BaseModel): + permission_key: str + permission_name: str + description: Optional[str] = None + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: str + updated_at: str + +@router.post( + "/create", + response_model=PermissionResponse, + operation_id="create-permission", + summary="Create Permission", + description="Create a new permission." +) +async def create_permission( + req: CreatePermissionRequest, +) -> PermissionResponse: + doc = await permission_service.create_permission(req.permission_key, req.permission_name, req.description) + + return PermissionResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/permission/query_permission.py b/apps/authentication/webapi/routes/permission/query_permission.py new file mode 100644 index 0000000..50769dd --- /dev/null +++ b/apps/authentication/webapi/routes/permission/query_permission.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + +class QueryPermissionRequest(BaseModel): + permission_key: Optional[str] = None + permission_name: Optional[str] = None + page: int = 1 + page_size: int = 10 + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + +class QueryPermissionResponse(BaseModel): + items: List[PermissionResponse] + total: int + page: int + page_size: int + +@router.post( + "/query", + response_model=QueryPermissionResponse, + operation_id="query-permission", + summary="Query Permissions (paginated)", + description="Query permissions with pagination and fuzzy search. Only Admin role allowed." +) +async def query_permissions( + req: QueryPermissionRequest, +) -> QueryPermissionResponse: + result = await permission_service.query_permissions(req.permission_key, req.permission_name, req.page, req.page_size) + items = [PermissionResponse(**item) for item in result["items"]] + return QueryPermissionResponse( + items=items, + total=result["total"], + page=result["page"], + page_size=result["page_size"] + ) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/permission/update_permission.py b/apps/authentication/webapi/routes/permission/update_permission.py new file mode 100644 index 0000000..5498bd6 --- /dev/null +++ b/apps/authentication/webapi/routes/permission/update_permission.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional +from backend.services.permission.permission_service import PermissionService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +permission_service = PermissionService() + +class UpdatePermissionRequest(BaseModel): + permission_id: str + permission_key: str + permission_name: str + description: Optional[str] = None + +class PermissionResponse(BaseModel): + id: str + permission_key: str + permission_name: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + +@router.post( + "/update", + response_model=PermissionResponse, + operation_id="update-permission", + summary="Update Permission", + description="Update an existing permission by id. Only Admin role allowed." +) +async def update_permission( + req: UpdatePermissionRequest, +) -> PermissionResponse: + doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, req.description) + return PermissionResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/__init__.py b/apps/authentication/webapi/routes/role/__init__.py new file mode 100644 index 0000000..eaad8a3 --- /dev/null +++ b/apps/authentication/webapi/routes/role/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from .create_role import router as create_role_router +from .update_role import router as update_role_router +from .query_role import router as query_role_router + +router = APIRouter() + +router.include_router(create_role_router, prefix="/role", tags=["role"]) +router.include_router(update_role_router, prefix="/role", tags=["role"]) +router.include_router(query_role_router, prefix="/role", tags=["role"]) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/create_role.py b/apps/authentication/webapi/routes/role/create_role.py new file mode 100644 index 0000000..2a21fae --- /dev/null +++ b/apps/authentication/webapi/routes/role/create_role.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class CreateRoleRequest(BaseModel): + role_key: str + role_name: str + role_description: Optional[str] = None + role_level: int + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +@router.post( + "/create", + response_model=RoleResponse, + operation_id="create-role", + summary="Create Role", + description="Create a new role." +) +async def create_role( + req: CreateRoleRequest, +) -> RoleResponse: + doc = await role_service.create_role(req.role_key, req.role_name, req.role_description, req.role_level) + return RoleResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/query_role.py b/apps/authentication/webapi/routes/role/query_role.py new file mode 100644 index 0000000..8abc853 --- /dev/null +++ b/apps/authentication/webapi/routes/role/query_role.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class QueryRoleRequest(BaseModel): + role_key: Optional[str] = None + role_name: Optional[str] = None + page: int = 1 + page_size: int = 10 + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +class QueryRoleResponse(BaseModel): + items: List[RoleResponse] + total: int + page: int + page_size: int + +@router.post( + "/query", + response_model=QueryRoleResponse, + operation_id="query-role", + summary="Query Roles (paginated)", + description="Query roles with pagination and fuzzy search. Only Admin role allowed." +) +async def query_roles( + req: QueryRoleRequest, +) -> QueryRoleResponse: + result = await role_service.query_roles(req.role_key, req.role_name, req.page, req.page_size) + items = [RoleResponse(**item) for item in result["items"]] + return QueryRoleResponse( + items=items, + total=result["total"], + page=result["page"], + page_size=result["page_size"] + ) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/update_role.py b/apps/authentication/webapi/routes/role/update_role.py new file mode 100644 index 0000000..862f79d --- /dev/null +++ b/apps/authentication/webapi/routes/role/update_role.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class UpdateRoleRequest(BaseModel): + role_id: str + role_key: str + role_name: str + role_description: Optional[str] = None + role_level: int + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: Optional[str] = None + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +@router.post( + "/update", + response_model=RoleResponse, + operation_id="update-role", + summary="Update Role", + description="Update an existing role by id. Only Admin role allowed." +) +async def update_role( + req: UpdateRoleRequest, +) -> RoleResponse: + doc = await role_service.update_role(req.role_id, req.role_key, req.role_name, req.role_description, req.role_level) + return RoleResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/user/__init__.py b/apps/authentication/webapi/routes/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/webapi/routes/user/assign_role.py b/apps/authentication/webapi/routes/user/assign_role.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/webapi/routes/user/remove_role.py b/apps/authentication/webapi/routes/user/remove_role.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notification/.env.local b/apps/notification/.env.local new file mode 100644 index 0000000..fc8415f --- /dev/null +++ b/apps/notification/.env.local @@ -0,0 +1 @@ +export RABBITMQ_HOST=localhost \ No newline at end of file From 795c1262c60255d3110d2696ebffa4e0f74f450b Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 09:31:37 +0800 Subject: [PATCH 02/19] feat(role_management): add assign permissions to role api --- .../backend/infra/permission/role_handler.py | 24 +++++++++++- .../services/permission/role_service.py | 8 +++- .../webapi/routes/role/__init__.py | 4 +- .../webapi/routes/role/assign_permissions.py | 38 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 apps/authentication/webapi/routes/role/assign_permissions.py diff --git a/apps/authentication/backend/infra/permission/role_handler.py b/apps/authentication/backend/infra/permission/role_handler.py index b18df20..83a1bbd 100644 --- a/apps/authentication/backend/infra/permission/role_handler.py +++ b/apps/authentication/backend/infra/permission/role_handler.py @@ -2,7 +2,7 @@ from typing import Optional, List, Tuple from fastapi.exceptions import RequestValidationError -from backend.models.permission.models import RoleDoc +from backend.models.permission.models import RoleDoc, PermissionDoc from beanie import PydanticObjectId from datetime import datetime @@ -71,3 +71,25 @@ class RoleHandler: total = await cursor.count() docs = await cursor.skip(skip).limit(limit).to_list() return docs, total + + async def assign_permissions_to_role(self, role_id: PydanticObjectId, permission_ids: List[str]) -> Optional[RoleDoc]: + """Assign permissions to a role by updating the permission_ids field""" + if not role_id or not permission_ids: + raise RequestValidationError("role_id and permission_ids are required.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("Role not found.") + + # Validate that all permission_ids exist in the permission collection + for permission_id in permission_ids: + permission_doc = await PermissionDoc.get(PydanticObjectId(permission_id)) + if not permission_doc: + raise RequestValidationError(f"Permission with id {permission_id} not found.") + + # Remove duplicates from permission_ids + unique_permission_ids = list(dict.fromkeys(permission_ids)) + + doc.permission_ids = unique_permission_ids + doc.updated_at = datetime.now() + await doc.save() + return doc diff --git a/apps/authentication/backend/services/permission/role_service.py b/apps/authentication/backend/services/permission/role_service.py index 8aba63e..0c15b0c 100644 --- a/apps/authentication/backend/services/permission/role_service.py +++ b/apps/authentication/backend/services/permission/role_service.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from fastapi.exceptions import RequestValidationError @@ -33,4 +33,8 @@ class RoleService: "total": total, "page": page, "page_size": page_size - } \ No newline at end of file + } + + async def assign_permissions_to_role(self, role_id: str, permission_ids: List[str]) -> RoleDoc: + """Assign permissions to a role by updating the permission_ids field""" + return await self.role_handler.assign_permissions_to_role(PydanticObjectId(role_id), permission_ids) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/__init__.py b/apps/authentication/webapi/routes/role/__init__.py index eaad8a3..60573a0 100644 --- a/apps/authentication/webapi/routes/role/__init__.py +++ b/apps/authentication/webapi/routes/role/__init__.py @@ -2,9 +2,11 @@ from fastapi import APIRouter from .create_role import router as create_role_router from .update_role import router as update_role_router from .query_role import router as query_role_router +from .assign_permissions import router as assign_permissions_router router = APIRouter() router.include_router(create_role_router, prefix="/role", tags=["role"]) router.include_router(update_role_router, prefix="/role", tags=["role"]) -router.include_router(query_role_router, prefix="/role", tags=["role"]) \ No newline at end of file +router.include_router(query_role_router, prefix="/role", tags=["role"]) +router.include_router(assign_permissions_router, prefix="/role", tags=["role"]) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/assign_permissions.py b/apps/authentication/webapi/routes/role/assign_permissions.py new file mode 100644 index 0000000..7640520 --- /dev/null +++ b/apps/authentication/webapi/routes/role/assign_permissions.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List +from backend.services.permission.role_service import RoleService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +role_service = RoleService() + +class AssignPermissionsRequest(BaseModel): + role_id: str + permission_ids: List[str] + +class RoleResponse(BaseModel): + id: str + role_key: str + role_name: str + role_description: str + permission_ids: List[str] + role_level: int + created_at: datetime + updated_at: datetime + +@router.post( + "/assign-permissions", + response_model=RoleResponse, + operation_id="assign-permissions-to-role", + summary="Assign Permissions to Role", + description="Assign permissions to a role by updating the permission_ids field." +) +async def assign_permissions_to_role( + req: AssignPermissionsRequest, +) -> RoleResponse: + doc = await role_service.assign_permissions_to_role(req.role_id, req.permission_ids) + return RoleResponse(**doc.dict()) \ No newline at end of file From ca0bfb155f01f8a89ef500f82d232945885e87b8 Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 10:20:28 +0800 Subject: [PATCH 03/19] feat(role_management): add assign roles to user api --- .../infra/__init__.py} | 0 .../infra/permission/__init__.py} | 0 .../infra/permission/user_role_handler.py | 40 +++++++++++++++++++ .../backend/models/permission/__init__.py | 4 +- .../backend/models/permission/models.py | 15 ++++++- .../backend/models/user/models.py | 1 - .../services/user/user_management_service.py | 11 ++++- apps/authentication/webapi/routes/__init__.py | 4 +- .../webapi/routes/user/__init__.py | 6 +++ .../webapi/routes/user/assign_roles.py | 30 ++++++++++++++ 10 files changed, 105 insertions(+), 6 deletions(-) rename apps/authentication/{webapi/routes/user/assign_role.py => backend/infra/__init__.py} (100%) rename apps/authentication/{webapi/routes/user/remove_role.py => backend/infra/permission/__init__.py} (100%) create mode 100644 apps/authentication/backend/infra/permission/user_role_handler.py create mode 100644 apps/authentication/webapi/routes/user/assign_roles.py diff --git a/apps/authentication/webapi/routes/user/assign_role.py b/apps/authentication/backend/infra/__init__.py similarity index 100% rename from apps/authentication/webapi/routes/user/assign_role.py rename to apps/authentication/backend/infra/__init__.py diff --git a/apps/authentication/webapi/routes/user/remove_role.py b/apps/authentication/backend/infra/permission/__init__.py similarity index 100% rename from apps/authentication/webapi/routes/user/remove_role.py rename to apps/authentication/backend/infra/permission/__init__.py diff --git a/apps/authentication/backend/infra/permission/user_role_handler.py b/apps/authentication/backend/infra/permission/user_role_handler.py new file mode 100644 index 0000000..4975147 --- /dev/null +++ b/apps/authentication/backend/infra/permission/user_role_handler.py @@ -0,0 +1,40 @@ +from typing import Optional, List +from fastapi.exceptions import RequestValidationError +from backend.models.permission.models import RoleDoc, UserRoleDoc +from beanie import PydanticObjectId + + +class UserRoleHandler: + def __init__(self): + pass + + async def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> Optional[UserRoleDoc]: + """Assign roles to a user by updating or creating the UserRoleDoc""" + if not user_id or not role_ids: + raise RequestValidationError("user_id and role_ids are required.") + + # Validate that all role_ids exist in the role collection + for role_id in role_ids: + role_doc = await RoleDoc.get(PydanticObjectId(role_id)) + if not role_doc: + raise RequestValidationError(f"Role with id {role_id} not found.") + + # Remove duplicates from role_ids + unique_role_ids = list(dict.fromkeys(role_ids)) + + # Check if UserRoleDoc already exists for this user + existing_user_role = await UserRoleDoc.find_one(UserRoleDoc.user_id == user_id) + + if existing_user_role: + # Update existing UserRoleDoc + existing_user_role.role_ids = unique_role_ids + await existing_user_role.save() + return existing_user_role + else: + # Create new UserRoleDoc + user_role_doc = UserRoleDoc( + user_id=user_id, + role_ids=unique_role_ids + ) + await user_role_doc.insert() + return user_role_doc diff --git a/apps/authentication/backend/models/permission/__init__.py b/apps/authentication/backend/models/permission/__init__.py index 4a14d68..cf41ebd 100644 --- a/apps/authentication/backend/models/permission/__init__.py +++ b/apps/authentication/backend/models/permission/__init__.py @@ -1,3 +1,3 @@ -from .models import PermissionDoc, RoleDoc +from .models import PermissionDoc, RoleDoc, UserRoleDoc -permission_models = [PermissionDoc, RoleDoc] +permission_models = [PermissionDoc, RoleDoc, UserRoleDoc] diff --git a/apps/authentication/backend/models/permission/models.py b/apps/authentication/backend/models/permission/models.py index 423495d..b87c2f5 100644 --- a/apps/authentication/backend/models/permission/models.py +++ b/apps/authentication/backend/models/permission/models.py @@ -1,6 +1,6 @@ from beanie import Document from datetime import datetime -from typing import Optional +from typing import Optional, List class PermissionDoc(Document): @@ -34,4 +34,17 @@ class RoleDoc(Document): name = "_role" indexes = [ "role_level" + ] + +class UserRoleDoc(Document): + """User role doc""" + user_id: str + role_ids: Optional[List[str]] + + class Settings: + # Default collections created by Freeleaps for tenant databases use '_' prefix + # to prevent naming conflicts with tenant-created collections + name = "_user_role" + indexes = [ + "user_id" ] \ No newline at end of file diff --git a/apps/authentication/backend/models/user/models.py b/apps/authentication/backend/models/user/models.py index 97ad23a..2fe9c21 100644 --- a/apps/authentication/backend/models/user/models.py +++ b/apps/authentication/backend/models/user/models.py @@ -18,7 +18,6 @@ class UserAccountDoc(Document): service_plan_id: Optional[str] properties: UserAccountProperty capabilities: Capability - user_role_ids: List[str] user_role: int = AdministrativeRole.NONE preferred_region: UserRegion = UserRegion.ZH_CN diff --git a/apps/authentication/backend/services/user/user_management_service.py b/apps/authentication/backend/services/user/user_management_service.py index bccdacd..98d2fb6 100644 --- a/apps/authentication/backend/services/user/user_management_service.py +++ b/apps/authentication/backend/services/user/user_management_service.py @@ -1,5 +1,6 @@ +from backend.models.permission.models import UserRoleDoc from common.log.module_logger import ModuleLogger -from typing import Optional +from typing import Optional, List from backend.models.user.constants import ( NewUserMethod, @@ -16,6 +17,9 @@ from backend.infra.auth.user_auth_handler import ( from backend.infra.user_profile.user_profile_handler import ( UserProfileHandler, ) +from backend.infra.permission.user_role_handler import ( + UserRoleHandler, +) from common.log.log_utils import log_entry_exit_async from common.constants.region import UserRegion @@ -24,6 +28,7 @@ class UserManagementService: def __init__(self) -> None: self.user_auth_handler = UserAuthHandler() self.user_profile_handler = UserProfileHandler() + self.user_role_handler = UserRoleHandler() self.module_logger = ModuleLogger(sender_id=UserManagementService) @log_entry_exit_async @@ -97,3 +102,7 @@ class UserManagementService: async def get_account_by_id(self, user_id: str) -> UserAccountDoc: return await self.user_profile_handler.get_account_by_id(user_id) + + async def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> UserRoleDoc: + """Assign roles to a user by updating or creating the UserRoleDoc""" + return await self.user_role_handler.assign_roles_to_user(user_id, role_ids) diff --git a/apps/authentication/webapi/routes/__init__.py b/apps/authentication/webapi/routes/__init__.py index 19a5ec5..2c8a9ee 100644 --- a/apps/authentication/webapi/routes/__init__.py +++ b/apps/authentication/webapi/routes/__init__.py @@ -4,11 +4,13 @@ from .tokens import router as token_router from .auth import router as auth_router from .permission import router as permission_router from .role import router as role_router +from .user import router as user_router api_router = APIRouter(prefix="/auth") -api_router.include_router(signin_router, tags=["user"]) +api_router.include_router(signin_router, tags=["signin"]) api_router.include_router(token_router, tags=["token"]) api_router.include_router(auth_router, tags=["auth"]) api_router.include_router(permission_router, tags=["permission"]) api_router.include_router(role_router, tags=["role"]) +api_router.include_router(user_router, tags=["user"]) websocket_router = APIRouter() diff --git a/apps/authentication/webapi/routes/user/__init__.py b/apps/authentication/webapi/routes/user/__init__.py index e69de29..a114692 100644 --- a/apps/authentication/webapi/routes/user/__init__.py +++ b/apps/authentication/webapi/routes/user/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +from .assign_roles import router as assign_role_router + +router = APIRouter() + +router.include_router(assign_role_router, prefix="/user", tags=["user"]) diff --git a/apps/authentication/webapi/routes/user/assign_roles.py b/apps/authentication/webapi/routes/user/assign_roles.py new file mode 100644 index 0000000..0df9d5a --- /dev/null +++ b/apps/authentication/webapi/routes/user/assign_roles.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional +from backend.services.user.user_management_service import UserManagementService +from common.token.token_manager import TokenManager + +router = APIRouter() +token_manager = TokenManager() +user_management_service = UserManagementService() + +class AssignRolesRequest(BaseModel): + user_id: str + role_ids: List[str] + +class UserRoleResponse(BaseModel): + user_id: str + role_ids: Optional[List[str]] + +@router.post( + "/assign-roles", + response_model=UserRoleResponse, + operation_id="assign-roles-to-user", + summary="Assign Roles to User", + description="Assign roles to a user by updating or creating the UserRoleDoc." +) +async def assign_roles_to_user( + req: AssignRolesRequest, +) -> UserRoleResponse: + doc = await user_management_service.assign_roles_to_user(req.user_id, req.role_ids) + return UserRoleResponse(**doc.dict()) From 4f39f888c4f35208141e0200774e7e64bc7cecb5 Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 10:33:15 +0800 Subject: [PATCH 04/19] feat(role_management): add delete permission api --- .../infra/permission/permission_handler.py | 15 +++++++++++- .../services/permission/permission_service.py | 6 ++++- .../webapi/routes/permission/__init__.py | 4 +++- .../routes/permission/delete_permission.py | 23 +++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 apps/authentication/webapi/routes/permission/delete_permission.py diff --git a/apps/authentication/backend/infra/permission/permission_handler.py b/apps/authentication/backend/infra/permission/permission_handler.py index ea05694..5ee1ebf 100644 --- a/apps/authentication/backend/infra/permission/permission_handler.py +++ b/apps/authentication/backend/infra/permission/permission_handler.py @@ -2,7 +2,7 @@ from typing import Optional, List, Tuple from fastapi.exceptions import RequestValidationError -from backend.models.permission.models import PermissionDoc +from backend.models.permission.models import PermissionDoc, RoleDoc from beanie import PydanticObjectId from datetime import datetime @@ -74,3 +74,16 @@ class PermissionHandler: total = await cursor.count() docs = await cursor.skip(skip).limit(limit).to_list() return docs, total + + async def delete_permission(self, permission_id: PydanticObjectId) -> None: + """Delete a permission document after checking if it is referenced by any role""" + if not permission_id: + raise RequestValidationError("permission_id is required.") + # Check if any role references this permission + role = await RoleDoc.find_one({"permission_ids": str(permission_id)}) + if role: + raise RequestValidationError("Permission is referenced by a role and cannot be deleted.") + doc = await PermissionDoc.get(permission_id) + if not doc: + raise RequestValidationError("Permission not found.") + await doc.delete() diff --git a/apps/authentication/backend/services/permission/permission_service.py b/apps/authentication/backend/services/permission/permission_service.py index 7de6838..1c13488 100644 --- a/apps/authentication/backend/services/permission/permission_service.py +++ b/apps/authentication/backend/services/permission/permission_service.py @@ -29,4 +29,8 @@ class PermissionService: "total": total, "page": page, "page_size": page_size - } \ No newline at end of file + } + + async def delete_permission(self, permission_id: str) -> None: + """Delete a permission document after checking if it is referenced by any role""" + return await self.permission_handler.delete_permission(PydanticObjectId(permission_id)) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/permission/__init__.py b/apps/authentication/webapi/routes/permission/__init__.py index d79f009..cf1aafb 100644 --- a/apps/authentication/webapi/routes/permission/__init__.py +++ b/apps/authentication/webapi/routes/permission/__init__.py @@ -2,10 +2,12 @@ from fastapi import APIRouter from .create_permission import router as cp_router from .query_permission import router as qp_router from .update_permission import router as up_router +from .delete_permission import router as delp_router router = APIRouter() router.include_router(cp_router, prefix="/permission", tags=["permission"]) router.include_router(qp_router, prefix="/permission", tags=["permission"]) -router.include_router(up_router, prefix="/permission", tags=["permission"]) \ No newline at end of file +router.include_router(up_router, prefix="/permission", tags=["permission"]) +router.include_router(delp_router, prefix="/permission", tags=["permission"]) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/permission/delete_permission.py b/apps/authentication/webapi/routes/permission/delete_permission.py new file mode 100644 index 0000000..eef7b88 --- /dev/null +++ b/apps/authentication/webapi/routes/permission/delete_permission.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from backend.services.permission.permission_service import PermissionService + +router = APIRouter() +permission_service = PermissionService() + +class DeletePermissionRequest(BaseModel): + permission_id: str + +class DeletePermissionResponse(BaseModel): + success: bool + +@router.post( + "/delete", + response_model=DeletePermissionResponse, + operation_id="delete-permission", + summary="Delete Permission", + description="Delete a permission after checking if it is referenced by any role." +) +async def delete_permission(req: DeletePermissionRequest) -> DeletePermissionResponse: + await permission_service.delete_permission(req.permission_id) + return DeletePermissionResponse(success=True) \ No newline at end of file From 419e58da0c4d93c623b98daa8cf9c3bb2185c7d6 Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 10:47:59 +0800 Subject: [PATCH 05/19] feat(role_management): add delete role api --- .../infra/permission/permission_handler.py | 36 +++++++++++-------- .../backend/infra/permission/role_handler.py | 20 ++++++++++- .../backend/models/permission/models.py | 2 ++ .../services/permission/role_service.py | 6 +++- .../webapi/providers/permission_initialize.py | 2 ++ .../routes/permission/create_permission.py | 6 ++-- .../webapi/routes/role/__init__.py | 4 ++- .../webapi/routes/role/delete_role.py | 23 ++++++++++++ 8 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 apps/authentication/webapi/routes/role/delete_role.py diff --git a/apps/authentication/backend/infra/permission/permission_handler.py b/apps/authentication/backend/infra/permission/permission_handler.py index 5ee1ebf..586fb6a 100644 --- a/apps/authentication/backend/infra/permission/permission_handler.py +++ b/apps/authentication/backend/infra/permission/permission_handler.py @@ -6,6 +6,7 @@ from backend.models.permission.models import PermissionDoc, RoleDoc from beanie import PydanticObjectId from datetime import datetime + class PermissionHandler: def __init__(self): pass @@ -39,21 +40,25 @@ class PermissionHandler: doc = await PermissionDoc.get(permission_id) if not doc: raise RequestValidationError("Permission not found.") + if doc.is_default: + raise RequestValidationError("Default permission cannot be updated.") # Check for uniqueness (exclude self) - if permission_key and permission_name: - conflict = await PermissionDoc.find_one({ - "$and": [ - {"_id": {"$ne": permission_id}}, - {"$or": [ - {str(PermissionDoc.permission_key): permission_key}, - {str(PermissionDoc.permission_name): permission_name} - ]} - ] - }) - if conflict: - raise RequestValidationError("Permission name or permission key already exists.") - doc.permission_key = permission_key + conflict = await PermissionDoc.find_one({ + "$and": [ + {"_id": {"$ne": permission_id}}, + {"$or": [ + {str(PermissionDoc.permission_key): permission_key}, + {str(PermissionDoc.permission_name): permission_name} + ]} + ] + }) + if conflict: + raise RequestValidationError("Permission name or permission key already exists.") + doc.permission_key = permission_key + doc.permission_name = permission_name + doc.description = description doc.updated_at = datetime.now() + await doc.save() return doc @@ -76,7 +81,7 @@ class PermissionHandler: return docs, total async def delete_permission(self, permission_id: PydanticObjectId) -> None: - """Delete a permission document after checking if it is referenced by any role""" + """Delete a permission document after checking if it is referenced by any role and is not default""" if not permission_id: raise RequestValidationError("permission_id is required.") # Check if any role references this permission @@ -86,4 +91,7 @@ class PermissionHandler: doc = await PermissionDoc.get(permission_id) if not doc: raise RequestValidationError("Permission not found.") + # Check if the permission is default + if doc.is_default: + raise RequestValidationError("Default permission cannot be deleted.") await doc.delete() diff --git a/apps/authentication/backend/infra/permission/role_handler.py b/apps/authentication/backend/infra/permission/role_handler.py index 83a1bbd..18b6136 100644 --- a/apps/authentication/backend/infra/permission/role_handler.py +++ b/apps/authentication/backend/infra/permission/role_handler.py @@ -2,7 +2,7 @@ from typing import Optional, List, Tuple from fastapi.exceptions import RequestValidationError -from backend.models.permission.models import RoleDoc, PermissionDoc +from backend.models.permission.models import RoleDoc, PermissionDoc, UserRoleDoc from beanie import PydanticObjectId from datetime import datetime @@ -39,6 +39,8 @@ class RoleHandler: doc = await RoleDoc.get(role_id) if not doc: raise RequestValidationError("role not found.") + if doc.is_default: + raise RequestValidationError("Default role cannot be updated.") # Check for uniqueness (exclude self) conflict = await RoleDoc.find_one({ "$and": [ @@ -93,3 +95,19 @@ class RoleHandler: doc.updated_at = datetime.now() await doc.save() return doc + + async def delete_role(self, role_id: PydanticObjectId) -> None: + """Delete a role document after checking if it is referenced by any user and is not default""" + if not role_id: + raise RequestValidationError("role_id is required.") + # Check if any user references this role + user_role = await UserRoleDoc.find_one({"role_ids": str(role_id)}) + if user_role: + raise RequestValidationError("Role is referenced by a user and cannot be deleted.") + doc = await RoleDoc.get(role_id) + if not doc: + raise RequestValidationError("Role not found.") + # Check if the role is default + if doc.is_default: + raise RequestValidationError("Default role cannot be deleted.") + await doc.delete() diff --git a/apps/authentication/backend/models/permission/models.py b/apps/authentication/backend/models/permission/models.py index b87c2f5..1197613 100644 --- a/apps/authentication/backend/models/permission/models.py +++ b/apps/authentication/backend/models/permission/models.py @@ -9,6 +9,7 @@ class PermissionDoc(Document): description: Optional[str] = None # Description of the permission, optional created_at: datetime = datetime.now() # Creation timestamp, auto-generated updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + is_default: bool = False class Settings: # Default collections created by Freeleaps for tenant databases use '_' prefix @@ -27,6 +28,7 @@ class RoleDoc(Document): role_level: int created_at: datetime = datetime.now() # Creation timestamp, auto-generated updated_at: datetime = datetime.now() # Last update timestamp, auto-updated + is_default: bool = False class Settings: # Default collections created by Freeleaps for tenant databases use '_' prefix diff --git a/apps/authentication/backend/services/permission/role_service.py b/apps/authentication/backend/services/permission/role_service.py index 0c15b0c..081ba77 100644 --- a/apps/authentication/backend/services/permission/role_service.py +++ b/apps/authentication/backend/services/permission/role_service.py @@ -37,4 +37,8 @@ class RoleService: async def assign_permissions_to_role(self, role_id: str, permission_ids: List[str]) -> RoleDoc: """Assign permissions to a role by updating the permission_ids field""" - return await self.role_handler.assign_permissions_to_role(PydanticObjectId(role_id), permission_ids) \ No newline at end of file + return await self.role_handler.assign_permissions_to_role(PydanticObjectId(role_id), permission_ids) + + async def delete_role(self, role_id: str) -> None: + """Delete a role document after checking if it is referenced by any user""" + return await self.role_handler.delete_role(PydanticObjectId(role_id)) \ No newline at end of file diff --git a/apps/authentication/webapi/providers/permission_initialize.py b/apps/authentication/webapi/providers/permission_initialize.py index 3107e12..03d8c4b 100644 --- a/apps/authentication/webapi/providers/permission_initialize.py +++ b/apps/authentication/webapi/providers/permission_initialize.py @@ -19,6 +19,7 @@ def register(app): permission_key=default_permission.value.permission_key, permission_name=default_permission.value.permission_name, description=default_permission.value.permission_description, + is_default=True, ).insert() default_permission_ids.append(str(doc.id)) logging.info(f"default permissions initialized {default_permission_ids}") @@ -32,6 +33,7 @@ def register(app): role_description=default_role.value.role_description, permission_ids=default_permission_ids, role_level=default_role.value.role_level, + is_default=True, ).insert() default_role_ids.append(str(doc.id)) logging.info(f"default roles initialized {default_role_ids}") diff --git a/apps/authentication/webapi/routes/permission/create_permission.py b/apps/authentication/webapi/routes/permission/create_permission.py index 19ee383..1da824f 100644 --- a/apps/authentication/webapi/routes/permission/create_permission.py +++ b/apps/authentication/webapi/routes/permission/create_permission.py @@ -1,3 +1,5 @@ +from datetime import datetime + from fastapi import APIRouter from pydantic import BaseModel from typing import Optional @@ -18,8 +20,8 @@ class PermissionResponse(BaseModel): permission_key: str permission_name: str description: Optional[str] = None - created_at: str - updated_at: str + created_at: datetime + updated_at: datetime @router.post( "/create", diff --git a/apps/authentication/webapi/routes/role/__init__.py b/apps/authentication/webapi/routes/role/__init__.py index 60573a0..61d7118 100644 --- a/apps/authentication/webapi/routes/role/__init__.py +++ b/apps/authentication/webapi/routes/role/__init__.py @@ -3,10 +3,12 @@ from .create_role import router as create_role_router from .update_role import router as update_role_router from .query_role import router as query_role_router from .assign_permissions import router as assign_permissions_router +from .delete_role import router as delete_role_router router = APIRouter() router.include_router(create_role_router, prefix="/role", tags=["role"]) router.include_router(update_role_router, prefix="/role", tags=["role"]) router.include_router(query_role_router, prefix="/role", tags=["role"]) -router.include_router(assign_permissions_router, prefix="/role", tags=["role"]) \ No newline at end of file +router.include_router(assign_permissions_router, prefix="/role", tags=["role"]) +router.include_router(delete_role_router, prefix="/role", tags=["role"]) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/delete_role.py b/apps/authentication/webapi/routes/role/delete_role.py new file mode 100644 index 0000000..44b502b --- /dev/null +++ b/apps/authentication/webapi/routes/role/delete_role.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from backend.services.permission.role_service import RoleService + +router = APIRouter() +role_service = RoleService() + +class DeleteRoleRequest(BaseModel): + role_id: str + +class DeleteRoleResponse(BaseModel): + success: bool + +@router.post( + "/delete", + response_model=DeleteRoleResponse, + operation_id="delete-role", + summary="Delete Role", + description="Delete a role after checking if it is referenced by any user." +) +async def delete_role(req: DeleteRoleRequest) -> DeleteRoleResponse: + await role_service.delete_role(req.role_id) + return DeleteRoleResponse(success=True) \ No newline at end of file From 4109018692dfcf0165e550ac5b7e61248420615a Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 13:09:33 +0800 Subject: [PATCH 06/19] feat(role_management): Update the JWT token generation logic to include new fields user_roles and user_permissions in the payload. --- .../backend/application/signin_hub.py | 32 +++++++-- .../backend/business/signin_manager.py | 72 ++++++++++++------- .../infra/permission/user_role_handler.py | 27 ++++++- .../services/user/user_management_service.py | 11 ++- .../signin/signin_with_email_and_code.py | 6 +- .../signin/signin_with_email_and_password.py | 6 +- 6 files changed, 120 insertions(+), 34 deletions(-) diff --git a/apps/authentication/backend/application/signin_hub.py b/apps/authentication/backend/application/signin_hub.py index f5f6b79..524fb8e 100644 --- a/apps/authentication/backend/application/signin_hub.py +++ b/apps/authentication/backend/application/signin_hub.py @@ -1,4 +1,8 @@ -from typing import Optional, Tuple +from typing import Optional, Tuple, List + +from backend.services.permission.permission_service import PermissionService +from backend.services.permission.role_service import RoleService +from common.constants.region import UserRegion from common.log.log_utils import log_entry_exit_async from backend.business.signin_manager import SignInManager from backend.models.user.constants import UserLoginAction @@ -13,10 +17,28 @@ class SignInHub: @log_entry_exit_async async def signin_with_email_and_code( - self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" - ) -> Tuple[int, Optional[int], Optional[str], Optional[str]]: + self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[UserRegion], Optional[List[str]], + Optional[List[str]]]: """ Interacts with the business layer to handle the sign-in process with email and code. + Try to signin with email and code. + create a new user account, if the email address has never been used before. + + Args: + email (str): email address + code (str): auth code to be verified + host (str): the host address by which the client access the frontend service + time_zone (Optional[str]): time zone of the frontend service + Returns: + [int, Optional[int], Optional[str], Optional[str]]: + - int: UserLoginAction + - Optional[int]: user role + - Optional[str]: user_id + - Optional[str]: flid + - Optional[str]: region + - Optional[str]: user role names + - Optional[str]: user permission keys """ return await self.signin_manager.signin_with_email_and_code( email=email, code=code, host=host, time_zone=time_zone @@ -25,7 +47,7 @@ class SignInHub: @log_entry_exit_async async def signin_with_email_and_password( self, email: str, password: str - ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str]]: + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[List[str]], Optional[List[str]]]: """Try to signin with email and password. Args: @@ -38,6 +60,8 @@ class SignInHub: - Optional[int]: user role - Optional[str]: user_id - Optional[str]: flid + - Optional[List[str]]: user role names + - Optional[List[str]]: user permission keys """ return await self.signin_manager.signin_with_email_and_password( email=email, password=password diff --git a/apps/authentication/backend/business/signin_manager.py b/apps/authentication/backend/business/signin_manager.py index e2dfb90..08faf16 100644 --- a/apps/authentication/backend/business/signin_manager.py +++ b/apps/authentication/backend/business/signin_manager.py @@ -1,9 +1,11 @@ import random -from typing import Tuple, Optional +from typing import Tuple, Optional, List + + from backend.services.auth.user_auth_service import UserAuthService +from common.constants.region import UserRegion from common.utils.region import RegionHandler from backend.models.user.constants import ( - UserLoginAction, NewUserMethod, ) from backend.models.user.constants import UserLoginAction @@ -36,7 +38,7 @@ class SignInManager: async def signin_with_email_and_code( self, email: str, code: str, host: str, time_zone: Optional[str] = "UTC" - ) -> Tuple[int, Optional[int], Optional[str], Optional[str]]: + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[UserRegion], Optional[str], Optional[List[str]], Optional[List[str]]]: """Try to signin with email and code. create a new user account, if the email address has never been used before. @@ -44,6 +46,7 @@ class SignInManager: email (str): email address code (str): auth code to be verified host (str): the host address by which the client access the frontend service + time_zone (str, optional): timezone of the frontend service Returns: [int, Optional[int], Optional[str], Optional[str]]: @@ -51,6 +54,9 @@ class SignInManager: - Optional[int]: user role - Optional[str]: user_id - Optional[str]: flid + - Optional[str]: region + - Optional[str]: user role names + - Optional[str]: user permission keys """ # check if the user account exist user_id = await self.user_auth_service.get_user_id_by_email(email) @@ -67,7 +73,6 @@ class SignInManager: method=NewUserMethod.EMAIL, region=preferred_region ) ) - user_id = str(user_account.id) await self.user_management_service.initialize_new_user_data( user_id=str(user_account.id), @@ -80,6 +85,9 @@ class SignInManager: user_account = await self.user_management_service.get_account_by_id( user_id=user_id ) + role_names, permission_keys = await self.user_management_service.get_role_and_permission_by_user_id( + user_id=user_id + ) if await self.user_auth_service.is_flid_reset_required(user_id): return ( UserLoginAction.REVIEW_AND_REVISE_FLID, @@ -87,10 +95,11 @@ class SignInManager: user_id, email.split("@")[0], preferred_region, + role_names, + permission_keys, ) user_flid = await self.user_auth_service.get_user_flid(user_id) - if await self.user_auth_service.is_password_reset_required(user_id): return ( UserLoginAction.NEW_USER_SET_PASSWORD, @@ -98,25 +107,28 @@ class SignInManager: user_id, user_flid, preferred_region, + role_names, + permission_keys, ) - return ( UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, user_account.user_role, user_id, user_flid, preferred_region, + role_names, + permission_keys, ) else: await self.module_logger.log_warning( warning="The auth code is invalid.", properties={"email": email, "code": code}, ) - return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None + return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None, None async def signin_with_email_and_password( self, email: str, password: str - ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str]]: + ) -> Tuple[UserLoginAction, Optional[int], Optional[str], Optional[str], Optional[List[str]], Optional[List[str]]]: # check if the user account exist user_id = await self.user_auth_service.get_user_id_by_email(email) @@ -126,16 +138,18 @@ class SignInManager: if is_new_user: # cannot find the email address - return [UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None] + return (UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None) else: if await self.user_auth_service.is_password_reset_required(user_id): # password hasn't been set before, save password for the user - return [ + return ( UserLoginAction.NEW_USER_SET_PASSWORD, None, None, None, - ] + None, + None + ) else: if await self.user_auth_service.verify_user_with_password( user_id, password @@ -143,33 +157,39 @@ class SignInManager: user_account = await self.user_management_service.get_account_by_id( user_id=user_id ) - + role_names, permission_keys = await self.user_management_service.get_role_and_permission_by_user_id(user_id) if await self.user_auth_service.is_flid_reset_required(user_id): - return [ + return ( UserLoginAction.REVIEW_AND_REVISE_FLID, user_account.user_role, user_id, email.split("@")[0], - ] + role_names, + permission_keys, + ) user_flid = await self.user_auth_service.get_user_flid(user_id) # password verification passed - return [ + return ( UserLoginAction.USER_SIGNED_IN, user_account.user_role, user_id, user_flid, - ] + role_names, + permission_keys + ) else: # ask user to input password again. # TODO: we need to limit times of user to input the wrong password - return [ + return ( UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, None, None, None, - ] + None, + None + ) async def update_new_user_flid( self, user_id: str, user_flid: str @@ -192,26 +212,26 @@ class SignInManager: "code_depot_email": code_depot_email, }, ) - return [ + return ( UserLoginAction.REVIEW_AND_REVISE_FLID, "{}{}".format(user_flid, random.randint(100, 999)), - ] + ) await self.user_auth_service.update_flid(user_id, user_flid) if await self.user_auth_service.is_password_reset_required(user_id): - return [ + return ( UserLoginAction.NEW_USER_SET_PASSWORD, user_flid, - ] + ) else: - return [ + return ( UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, user_flid, - ] + ) else: - return [ + return ( UserLoginAction.REVIEW_AND_REVISE_FLID, "{}{}".format(user_flid, random.randint(100, 999)), - ] + ) async def try_signin_with_email(self, email: str, host: str) -> UserLoginAction: """try signin through email, generate auth code and send to the email address diff --git a/apps/authentication/backend/infra/permission/user_role_handler.py b/apps/authentication/backend/infra/permission/user_role_handler.py index 4975147..bda926a 100644 --- a/apps/authentication/backend/infra/permission/user_role_handler.py +++ b/apps/authentication/backend/infra/permission/user_role_handler.py @@ -1,6 +1,6 @@ from typing import Optional, List from fastapi.exceptions import RequestValidationError -from backend.models.permission.models import RoleDoc, UserRoleDoc +from backend.models.permission.models import RoleDoc, UserRoleDoc, PermissionDoc from beanie import PydanticObjectId @@ -38,3 +38,28 @@ class UserRoleHandler: ) await user_role_doc.insert() return user_role_doc + + async def get_role_and_permission_by_user_id(self, user_id: str) -> tuple[list[str], list[str]]: + """Get all role names and permission keys for a user by user_id""" + # Query user roles + user_role_doc = await UserRoleDoc.find_one(UserRoleDoc.user_id == user_id) + if not user_role_doc or not user_role_doc.role_ids: + # No roles assigned + return [], [] + # Query all roles by role_ids + roles = await RoleDoc.find({"_id": {"$in": [PydanticObjectId(rid) for rid in user_role_doc.role_ids]}}).to_list() + role_names = [role.role_name for role in roles] + # Collect all permission_ids from all roles + all_permission_ids = [] + for role in roles: + if role.permission_ids: + all_permission_ids.extend(role.permission_ids) + # Remove duplicates + unique_permission_ids = list(dict.fromkeys(all_permission_ids)) + # Query all permissions by permission_ids + if unique_permission_ids: + permissions = await PermissionDoc.find({"_id": {"$in": [PydanticObjectId(pid) for pid in unique_permission_ids]}}).to_list() + permission_keys = [perm.permission_key for perm in permissions] + else: + permission_keys = [] + return role_names, permission_keys diff --git a/apps/authentication/backend/services/user/user_management_service.py b/apps/authentication/backend/services/user/user_management_service.py index 98d2fb6..5db8887 100644 --- a/apps/authentication/backend/services/user/user_management_service.py +++ b/apps/authentication/backend/services/user/user_management_service.py @@ -1,6 +1,6 @@ from backend.models.permission.models import UserRoleDoc from common.log.module_logger import ModuleLogger -from typing import Optional, List +from typing import Optional, List, Tuple from backend.models.user.constants import ( NewUserMethod, @@ -106,3 +106,12 @@ class UserManagementService: async def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> UserRoleDoc: """Assign roles to a user by updating or creating the UserRoleDoc""" return await self.user_role_handler.assign_roles_to_user(user_id, role_ids) + + async def get_role_and_permission_by_user_id(self, user_id: str) -> Tuple[List[str], List[str]]: + """Get user role names and permission keys by user id + Args: + user_id (str): user id + Returns: + Tuple[List[str], List[str]]: user role names and permission keys + """ + return await self.user_role_handler.get_role_and_permission_by_user_id(user_id) diff --git a/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py b/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py index 87f8f10..4b26399 100644 --- a/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py +++ b/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py @@ -58,6 +58,8 @@ async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: identity, flid, preferred_region, + user_role_names, + user_permission_keys ) = await SignInHub().signin_with_email_and_code( item.email, item.code, item.host, item.time_zone ) @@ -67,7 +69,7 @@ async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: ) if signed_in and identity and adminstrative_role: - subject = {"id": identity, "role": adminstrative_role} + subject = {"id": identity, "role": adminstrative_role, "role_names": user_role_names, "user_permissions": user_permission_keys} access_token = token_manager.create_access_token(subject=subject) refresh_token = token_manager.create_refresh_token(subject=subject) expires_in = datetime.now(timezone.utc) + timedelta( @@ -85,6 +87,8 @@ async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: "identity": identity, "expires_in": expires_in, "role": adminstrative_role, + "role_names": user_role_names, + "user_permissions": user_permission_keys, "flid": flid, "preferred_region": preferred_region, } diff --git a/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py b/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py index 1d2956f..28ffabe 100644 --- a/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py +++ b/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py @@ -57,6 +57,8 @@ async def signin_with_email_and_password( adminstrative_role, identity, flid, + user_role_names, + user_permission_keys ) = await SignInHub().signin_with_email_and_password(item.email, item.password) logging.debug( @@ -64,7 +66,7 @@ async def signin_with_email_and_password( ) if signed_in and adminstrative_role and identity: - subject = {"id": identity, "role": adminstrative_role} + subject = {"id": identity, "role": adminstrative_role, "role_names": user_role_names, "user_permissions": user_permission_keys} access_token = token_manager.create_access_token(subject=subject) refresh_token = token_manager.create_refresh_token(subject=subject) expires_in = datetime.now(timezone.utc) + timedelta( @@ -82,6 +84,8 @@ async def signin_with_email_and_password( "identity": identity, "expires_in": expires_in, "role": adminstrative_role, + "role_names": user_role_names, + "user_permissions": user_permission_keys, "flid": flid, } return JSONResponse(content=jsonable_encoder(result)) From b8be65615b451b190de6c06f62a1fe1a76cd2ee4 Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 14:51:00 +0800 Subject: [PATCH 07/19] feat(role_management): Add a universal Depends for permission verification. --- .../backend/models/permission/constants.py | 1 + apps/authentication/common/config/__init__.py | 0 .../common/constants/jwt_constants.py | 2 + apps/authentication/common/utils/jwt_token.py | 77 +++++++++++++++++++ .../webapi/providers/permission_initialize.py | 5 +- .../routes/permission/create_permission.py | 13 +++- .../routes/permission/delete_permission.py | 15 +++- .../routes/permission/update_permission.py | 16 +++- .../webapi/routes/role/assign_permissions.py | 6 +- .../webapi/routes/role/create_role.py | 13 +++- .../webapi/routes/role/delete_role.py | 15 +++- .../webapi/routes/role/update_role.py | 13 +++- .../signin/signin_with_email_and_code.py | 7 +- .../signin/signin_with_email_and_password.py | 7 +- .../webapi/routes/user/assign_roles.py | 10 ++- apps/notification/.env.local | 1 - 16 files changed, 172 insertions(+), 29 deletions(-) create mode 100644 apps/authentication/common/config/__init__.py create mode 100644 apps/authentication/common/constants/jwt_constants.py create mode 100644 apps/authentication/common/utils/jwt_token.py delete mode 100644 apps/notification/.env.local diff --git a/apps/authentication/backend/models/permission/constants.py b/apps/authentication/backend/models/permission/constants.py index d957893..0499d50 100644 --- a/apps/authentication/backend/models/permission/constants.py +++ b/apps/authentication/backend/models/permission/constants.py @@ -26,6 +26,7 @@ class DefaultPermission: class DefaultPermissionEnum(Enum): CHANGE_ROLES = DefaultPermission("change:roles", "Change roles", "Add/Update/Delete roles") CHANGE_PERMISSIONS = DefaultPermission("change:permissions", "Change permissions", "Add/Update/Remove permissions") + ASSIGN_ROLES = DefaultPermission("assign:roles", "Assign roles", "Assign roles to user") class AdministrativeRole(IntEnum): diff --git a/apps/authentication/common/config/__init__.py b/apps/authentication/common/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/common/constants/jwt_constants.py b/apps/authentication/common/constants/jwt_constants.py new file mode 100644 index 0000000..496b745 --- /dev/null +++ b/apps/authentication/common/constants/jwt_constants.py @@ -0,0 +1,2 @@ +USER_ROLE_NAMES = "role_names" +USER_PERMISSIONS = "user_permissions" \ No newline at end of file diff --git a/apps/authentication/common/utils/jwt_token.py b/apps/authentication/common/utils/jwt_token.py new file mode 100644 index 0000000..9247816 --- /dev/null +++ b/apps/authentication/common/utils/jwt_token.py @@ -0,0 +1,77 @@ +from typing import List + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import jwt, JWTError + +from common.config.app_settings import app_settings +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS + +security = HTTPBearer() + + +class CurrentUser: + def __init__(self, user_id: str, user_role_names: List[str], user_permission_keys: List[str]): + self.user_id = user_id + self.user_role_names = user_role_names + self.user_permission_keys = user_permission_keys + + def has_all_permissions(self, permissions: List[str]) -> bool: + """Check if the user has all the specified permissions""" + if not permissions: + return True + return all(p in self.user_permission_keys for p in permissions) + + def has_any_permissions(self, permissions: List[str]) -> bool: + """Check if the user has at least one of the specified permissions""" + if not permissions: + return True + return any(p in self.user_permission_keys for p in permissions) + + +def decode_jwt_token(token: str): + payload = jwt.decode( + token, + app_settings.JWT_SECRET_KEY, + algorithms=[app_settings.JWT_ALGORITHM], + ) + return payload + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> CurrentUser: + try: + payload = decode_jwt_token(credentials.credentials) + user = payload.get("subject") + if not user or "id" not in user: + raise HTTPException(status_code=401, detail="Invalid authentication token") + return CurrentUser(user.get("id"), user.get(USER_ROLE_NAMES), user.get(USER_PERMISSIONS)) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid authentication token") + + +def has_all_permissions( + permissions: List[str], +): + """Check if the user has all the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(get_current_user)): + if not current_user.has_all_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency + + +def has_any_permissions( + permissions: List[str] +): + """Check if the user has at least one of the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(get_current_user)): + if not current_user.has_any_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency diff --git a/apps/authentication/webapi/providers/permission_initialize.py b/apps/authentication/webapi/providers/permission_initialize.py index 03d8c4b..7054360 100644 --- a/apps/authentication/webapi/providers/permission_initialize.py +++ b/apps/authentication/webapi/providers/permission_initialize.py @@ -12,7 +12,10 @@ def register(app): async def init_admin_permission(): # Initialize permissions if not exist default_permission_ids = [] - for default_permission in [DefaultPermissionEnum.CHANGE_PERMISSIONS, DefaultPermissionEnum.CHANGE_ROLES]: + for default_permission in \ + [DefaultPermissionEnum.CHANGE_PERMISSIONS, + DefaultPermissionEnum.CHANGE_ROLES, + DefaultPermissionEnum.ASSIGN_ROLES]: if not await PermissionDoc.find_one( {str(PermissionDoc.permission_key): default_permission.value.permission_key}): doc = await PermissionDoc( diff --git a/apps/authentication/webapi/routes/permission/create_permission.py b/apps/authentication/webapi/routes/permission/create_permission.py index 1da824f..9d8af34 100644 --- a/apps/authentication/webapi/routes/permission/create_permission.py +++ b/apps/authentication/webapi/routes/permission/create_permission.py @@ -1,20 +1,25 @@ from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import Optional + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() permission_service = PermissionService() + class CreatePermissionRequest(BaseModel): permission_key: str permission_name: str description: Optional[str] = None + class PermissionResponse(BaseModel): id: str permission_key: str @@ -23,6 +28,7 @@ class PermissionResponse(BaseModel): created_at: datetime updated_at: datetime + @router.post( "/create", response_model=PermissionResponse, @@ -31,8 +37,9 @@ class PermissionResponse(BaseModel): description="Create a new permission." ) async def create_permission( - req: CreatePermissionRequest, + req: CreatePermissionRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) ) -> PermissionResponse: doc = await permission_service.create_permission(req.permission_key, req.permission_name, req.description) - return PermissionResponse(**doc.dict()) \ No newline at end of file + return PermissionResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/permission/delete_permission.py b/apps/authentication/webapi/routes/permission/delete_permission.py index eef7b88..efcd0d1 100644 --- a/apps/authentication/webapi/routes/permission/delete_permission.py +++ b/apps/authentication/webapi/routes/permission/delete_permission.py @@ -1,16 +1,22 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService +from common.utils.jwt_token import has_all_permissions router = APIRouter() permission_service = PermissionService() + class DeletePermissionRequest(BaseModel): permission_id: str + class DeletePermissionResponse(BaseModel): success: bool + @router.post( "/delete", response_model=DeletePermissionResponse, @@ -18,6 +24,9 @@ class DeletePermissionResponse(BaseModel): summary="Delete Permission", description="Delete a permission after checking if it is referenced by any role." ) -async def delete_permission(req: DeletePermissionRequest) -> DeletePermissionResponse: +async def delete_permission( + req: DeletePermissionRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) +) -> DeletePermissionResponse: await permission_service.delete_permission(req.permission_id) - return DeletePermissionResponse(success=True) \ No newline at end of file + return DeletePermissionResponse(success=True) diff --git a/apps/authentication/webapi/routes/permission/update_permission.py b/apps/authentication/webapi/routes/permission/update_permission.py index 5498bd6..fa811e5 100644 --- a/apps/authentication/webapi/routes/permission/update_permission.py +++ b/apps/authentication/webapi/routes/permission/update_permission.py @@ -1,21 +1,26 @@ from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import Optional + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() permission_service = PermissionService() + class UpdatePermissionRequest(BaseModel): permission_id: str permission_key: str permission_name: str description: Optional[str] = None + class PermissionResponse(BaseModel): id: str permission_key: str @@ -24,6 +29,7 @@ class PermissionResponse(BaseModel): created_at: datetime updated_at: datetime + @router.post( "/update", response_model=PermissionResponse, @@ -32,7 +38,9 @@ class PermissionResponse(BaseModel): description="Update an existing permission by id. Only Admin role allowed." ) async def update_permission( - req: UpdatePermissionRequest, + req: UpdatePermissionRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) ) -> PermissionResponse: - doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, req.description) - return PermissionResponse(**doc.dict()) \ No newline at end of file + doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, + req.description) + return PermissionResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/role/assign_permissions.py b/apps/authentication/webapi/routes/role/assign_permissions.py index 7640520..c18aa11 100644 --- a/apps/authentication/webapi/routes/role/assign_permissions.py +++ b/apps/authentication/webapi/routes/role/assign_permissions.py @@ -1,10 +1,13 @@ from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import List + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -33,6 +36,7 @@ class RoleResponse(BaseModel): ) async def assign_permissions_to_role( req: AssignPermissionsRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.assign_permissions_to_role(req.role_id, req.permission_ids) return RoleResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/create_role.py b/apps/authentication/webapi/routes/role/create_role.py index 2a21fae..2677591 100644 --- a/apps/authentication/webapi/routes/role/create_role.py +++ b/apps/authentication/webapi/routes/role/create_role.py @@ -1,21 +1,26 @@ from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import Optional, List + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() role_service = RoleService() + class CreateRoleRequest(BaseModel): role_key: str role_name: str role_description: Optional[str] = None role_level: int + class RoleResponse(BaseModel): id: str role_key: str @@ -26,6 +31,7 @@ class RoleResponse(BaseModel): created_at: datetime updated_at: datetime + @router.post( "/create", response_model=RoleResponse, @@ -34,7 +40,8 @@ class RoleResponse(BaseModel): description="Create a new role." ) async def create_role( - req: CreateRoleRequest, + req: CreateRoleRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.create_role(req.role_key, req.role_name, req.role_description, req.role_level) - return RoleResponse(**doc.dict()) \ No newline at end of file + return RoleResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/role/delete_role.py b/apps/authentication/webapi/routes/role/delete_role.py index 44b502b..5c34823 100644 --- a/apps/authentication/webapi/routes/role/delete_role.py +++ b/apps/authentication/webapi/routes/role/delete_role.py @@ -1,16 +1,22 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService +from common.utils.jwt_token import has_all_permissions router = APIRouter() role_service = RoleService() + class DeleteRoleRequest(BaseModel): role_id: str + class DeleteRoleResponse(BaseModel): success: bool + @router.post( "/delete", response_model=DeleteRoleResponse, @@ -18,6 +24,9 @@ class DeleteRoleResponse(BaseModel): summary="Delete Role", description="Delete a role after checking if it is referenced by any user." ) -async def delete_role(req: DeleteRoleRequest) -> DeleteRoleResponse: +async def delete_role( + req: DeleteRoleRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) +) -> DeleteRoleResponse: await role_service.delete_role(req.role_id) - return DeleteRoleResponse(success=True) \ No newline at end of file + return DeleteRoleResponse(success=True) diff --git a/apps/authentication/webapi/routes/role/update_role.py b/apps/authentication/webapi/routes/role/update_role.py index 862f79d..cd6a445 100644 --- a/apps/authentication/webapi/routes/role/update_role.py +++ b/apps/authentication/webapi/routes/role/update_role.py @@ -1,15 +1,19 @@ from datetime import datetime -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from typing import Optional, List + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() role_service = RoleService() + class UpdateRoleRequest(BaseModel): role_id: str role_key: str @@ -17,6 +21,7 @@ class UpdateRoleRequest(BaseModel): role_description: Optional[str] = None role_level: int + class RoleResponse(BaseModel): id: str role_key: str @@ -27,6 +32,7 @@ class RoleResponse(BaseModel): created_at: datetime updated_at: datetime + @router.post( "/update", response_model=RoleResponse, @@ -35,7 +41,8 @@ class RoleResponse(BaseModel): description="Update an existing role by id. Only Admin role allowed." ) async def update_role( - req: UpdateRoleRequest, + req: UpdateRoleRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.update_role(req.role_id, req.role_key, req.role_name, req.role_description, req.role_level) - return RoleResponse(**doc.dict()) \ No newline at end of file + return RoleResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py b/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py index 4b26399..5410356 100644 --- a/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py +++ b/apps/authentication/webapi/routes/signin/signin_with_email_and_code.py @@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from backend.application.signin_hub import SignInHub +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS from common.token.token_manager import TokenManager @@ -69,7 +70,7 @@ async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: ) if signed_in and identity and adminstrative_role: - subject = {"id": identity, "role": adminstrative_role, "role_names": user_role_names, "user_permissions": user_permission_keys} + subject = {"id": identity, "role": adminstrative_role, USER_ROLE_NAMES: user_role_names, USER_PERMISSIONS: user_permission_keys} access_token = token_manager.create_access_token(subject=subject) refresh_token = token_manager.create_refresh_token(subject=subject) expires_in = datetime.now(timezone.utc) + timedelta( @@ -87,8 +88,8 @@ async def signin_with_email_and_code(item: RequestIn) -> ResponseOut: "identity": identity, "expires_in": expires_in, "role": adminstrative_role, - "role_names": user_role_names, - "user_permissions": user_permission_keys, + USER_ROLE_NAMES: user_role_names, + USER_PERMISSIONS: user_permission_keys, "flid": flid, "preferred_region": preferred_region, } diff --git a/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py b/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py index 28ffabe..d1cdcf4 100644 --- a/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py +++ b/apps/authentication/webapi/routes/signin/signin_with_email_and_password.py @@ -10,6 +10,7 @@ from fastapi import Depends, HTTPException from starlette.status import HTTP_401_UNAUTHORIZED from backend.application.signin_hub import SignInHub +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS from common.token.token_manager import TokenManager router = APIRouter() @@ -66,7 +67,7 @@ async def signin_with_email_and_password( ) if signed_in and adminstrative_role and identity: - subject = {"id": identity, "role": adminstrative_role, "role_names": user_role_names, "user_permissions": user_permission_keys} + subject = {"id": identity, "role": adminstrative_role, USER_ROLE_NAMES: user_role_names, USER_PERMISSIONS: user_permission_keys} access_token = token_manager.create_access_token(subject=subject) refresh_token = token_manager.create_refresh_token(subject=subject) expires_in = datetime.now(timezone.utc) + timedelta( @@ -84,8 +85,8 @@ async def signin_with_email_and_password( "identity": identity, "expires_in": expires_in, "role": adminstrative_role, - "role_names": user_role_names, - "user_permissions": user_permission_keys, + USER_ROLE_NAMES: user_role_names, + USER_PERMISSIONS: user_permission_keys, "flid": flid, } return JSONResponse(content=jsonable_encoder(result)) diff --git a/apps/authentication/webapi/routes/user/assign_roles.py b/apps/authentication/webapi/routes/user/assign_roles.py index 0df9d5a..4ad806c 100644 --- a/apps/authentication/webapi/routes/user/assign_roles.py +++ b/apps/authentication/webapi/routes/user/assign_roles.py @@ -1,21 +1,28 @@ from fastapi import APIRouter +from fastapi.params import Depends from pydantic import BaseModel from typing import List, Optional + +from backend.models.permission.constants import DefaultPermissionEnum from backend.services.user.user_management_service import UserManagementService from common.token.token_manager import TokenManager +from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() user_management_service = UserManagementService() + class AssignRolesRequest(BaseModel): user_id: str role_ids: List[str] + class UserRoleResponse(BaseModel): user_id: str role_ids: Optional[List[str]] + @router.post( "/assign-roles", response_model=UserRoleResponse, @@ -24,7 +31,8 @@ class UserRoleResponse(BaseModel): description="Assign roles to a user by updating or creating the UserRoleDoc." ) async def assign_roles_to_user( - req: AssignRolesRequest, + req: AssignRolesRequest, + _: bool = Depends(has_all_permissions([DefaultPermissionEnum.ASSIGN_ROLES.value.permission_key])), ) -> UserRoleResponse: doc = await user_management_service.assign_roles_to_user(req.user_id, req.role_ids) return UserRoleResponse(**doc.dict()) diff --git a/apps/notification/.env.local b/apps/notification/.env.local deleted file mode 100644 index fc8415f..0000000 --- a/apps/notification/.env.local +++ /dev/null @@ -1 +0,0 @@ -export RABBITMQ_HOST=localhost \ No newline at end of file From 641281066cd16c83470379f00e8146a7d42afe86 Mon Sep 17 00:00:00 2001 From: icecheng Date: Mon, 21 Jul 2025 17:51:08 +0800 Subject: [PATCH 08/19] feat(role_management): Add pytest for auth services, and add api test case for role management --- .../tests/api_tests/__init__.py | 0 .../tests/api_tests/role/__init__.py | 0 .../tests/api_tests/role/conftest.py | 10 ++ .../tests/api_tests/role/test_create_role.py | 117 ++++++++++++++ .../tests/api_tests/role/test_delete_role.py | 31 ++++ .../tests/api_tests/role/test_query_role.py | 58 +++++++ .../tests/api_tests/role/test_update_role.py | 153 ++++++++++++++++++ .../tests/api_tests/siginin/__init__.py | 0 .../tests/api_tests/siginin/conftest.py | 10 ++ .../test_signin_with_email_and_password.py | 22 +++ apps/authentication/tests/base/__init__.py | 0 .../tests/base/authentication_web.py | 64 ++++++++ apps/authentication/tests/base/config.py | 3 + apps/authentication/tests/conftest.py | 10 ++ 14 files changed, 478 insertions(+) create mode 100644 apps/authentication/tests/api_tests/__init__.py create mode 100644 apps/authentication/tests/api_tests/role/__init__.py create mode 100644 apps/authentication/tests/api_tests/role/conftest.py create mode 100644 apps/authentication/tests/api_tests/role/test_create_role.py create mode 100644 apps/authentication/tests/api_tests/role/test_delete_role.py create mode 100644 apps/authentication/tests/api_tests/role/test_query_role.py create mode 100644 apps/authentication/tests/api_tests/role/test_update_role.py create mode 100644 apps/authentication/tests/api_tests/siginin/__init__.py create mode 100644 apps/authentication/tests/api_tests/siginin/conftest.py create mode 100644 apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py create mode 100644 apps/authentication/tests/base/__init__.py create mode 100644 apps/authentication/tests/base/authentication_web.py create mode 100644 apps/authentication/tests/base/config.py create mode 100644 apps/authentication/tests/conftest.py diff --git a/apps/authentication/tests/api_tests/__init__.py b/apps/authentication/tests/api_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/role/__init__.py b/apps/authentication/tests/api_tests/role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/role/conftest.py b/apps/authentication/tests/api_tests/role/conftest.py new file mode 100644 index 0000000..f2aad5a --- /dev/null +++ b/apps/authentication/tests/api_tests/role/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web()->AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web diff --git a/apps/authentication/tests/api_tests/role/test_create_role.py b/apps/authentication/tests/api_tests/role/test_create_role.py new file mode 100644 index 0000000..a52db06 --- /dev/null +++ b/apps/authentication/tests/api_tests/role/test_create_role.py @@ -0,0 +1,117 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestCreateRole: + + @pytest.mark.asyncio + async def test_create_role_success(self, authentication_web: AuthenticationWeb): + """Test creating a role successfully with valid and unique role_key and role_name.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_success_{suffix}", + "role_name": f"Test Role Success {suffix}", + "role_description": "Role for testing success", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 200 + json = response.json() + assert json["role_key"] == role_data["role_key"] + assert json["role_name"] == role_data["role_name"] + assert json["role_description"] == role_data["role_description"] + assert json["role_level"] == role_data["role_level"] + assert json["id"] is not None + assert json["created_at"] is not None + assert json["updated_at"] is not None + + @pytest.mark.asyncio + async def test_create_role_fail_duplicate_role_key(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_dup_{suffix}", + "role_name": f"Test Role DupKey {suffix}", + "role_description": "desc", + "role_level": 1 + } + await authentication_web.create_role(role_data) + role_data2 = { + "role_key": f"test_role_key_dup_{suffix}", + "role_name": f"Test Role DupKey2 {suffix}", + "role_description": "desc2", + "role_level": 2 + } + response = await authentication_web.create_role(role_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_duplicate_role_name(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_dupname1_{suffix}", + "role_name": f"Test Role DupName {suffix}", + "role_description": "desc", + "role_level": 1 + } + await authentication_web.create_role(role_data) + role_data2 = { + "role_key": f"test_role_key_dupname2_{suffix}", + "role_name": f"Test Role DupName {suffix}", + "role_description": "desc2", + "role_level": 2 + } + response = await authentication_web.create_role(role_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_empty_role_key(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_key is empty.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": "", + "role_name": f"Test Role EmptyKey {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_fail_empty_role_name(self, authentication_web: AuthenticationWeb): + """Test creating a role fails when role_name is empty.""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_emptyname_{suffix}", + "role_name": "", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_role_success_empty_description(self, authentication_web: AuthenticationWeb): + """Test creating a role successfully when role_description is None (optional field).""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_emptydesc_{suffix}", + "role_name": f"Test Role EmptyDesc {suffix}", + "role_description": None, + "role_level": 1 + } + response = await authentication_web.create_role(role_data) + assert response.status_code == 200 + json = response.json() + assert json["role_key"] == role_data["role_key"] + assert json["role_name"] == role_data["role_name"] + assert json["role_description"] is None or json["role_description"] == "" + assert json["role_level"] == role_data["role_level"] + + + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/role/test_delete_role.py b/apps/authentication/tests/api_tests/role/test_delete_role.py new file mode 100644 index 0000000..b6f6918 --- /dev/null +++ b/apps/authentication/tests/api_tests/role/test_delete_role.py @@ -0,0 +1,31 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestDeleteRole: + + @pytest.mark.asyncio + async def test_delete_role_success(self, authentication_web: AuthenticationWeb): + """Test deleting a role successfully.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_{suffix}", + "role_name": f"delrole_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + resp = await authentication_web.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_role_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test deleting a role fails when role_id does not exist.""" + resp = await authentication_web.delete_role(role_data={"role_id": "000000000000000000000000"}) + assert resp.status_code == 422 or resp.status_code == 400 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/role/test_query_role.py b/apps/authentication/tests/api_tests/role/test_query_role.py new file mode 100644 index 0000000..574b138 --- /dev/null +++ b/apps/authentication/tests/api_tests/role/test_query_role.py @@ -0,0 +1,58 @@ +import random + +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +class TestQueryRole: + + @pytest.mark.asyncio + async def test_query_all_roles(self, authentication_web: AuthenticationWeb): + """Test querying all roles returns a list.""" + resp = await authentication_web.query_roles(params={}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + + @pytest.mark.asyncio + async def test_query_roles_by_key(self, authentication_web: AuthenticationWeb): + """Test querying roles by role_key with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_role({ + "role_key": f"querykey_{suffix}", + "role_name": f"querykey_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + resp = await authentication_web.query_roles(params={"role_key": f"querykey_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"querykey_{suffix}" in item["role_key"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_roles_by_name(self, authentication_web: AuthenticationWeb): + """Test querying roles by role_name with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_role({ + "role_key": f"queryname_{suffix}", + "role_name": f"queryname_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + resp = await authentication_web.query_roles(params={"role_name": f"queryname_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"queryname_{suffix}" in item["role_name"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_roles_pagination(self, authentication_web: AuthenticationWeb): + """Test querying roles with pagination.""" + resp = await authentication_web.query_roles(params={"page": 1, "page_size": 2}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + assert "page" in json + assert "page_size" in json diff --git a/apps/authentication/tests/api_tests/role/test_update_role.py b/apps/authentication/tests/api_tests/role/test_update_role.py new file mode 100644 index 0000000..848d7e0 --- /dev/null +++ b/apps/authentication/tests/api_tests/role/test_update_role.py @@ -0,0 +1,153 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestUpdateRole: + + @pytest.mark.asyncio + async def test_update_role_success(self, authentication_web: AuthenticationWeb): + """Test updating a role successfully with valid and unique fields.""" + suffix = str(random.randint(10000, 99999)) + # create firstly + role_data = { + "role_key": f"update_role_key_{suffix}", + "role_name": f"Update Role {suffix}", + "role_description": "desc", + "role_level": 1 + } + create_resp = await authentication_web.create_role(role_data) + role_id = create_resp.json()["id"] + # update + update_data = { + "role_id": role_id, + "role_key": f"update_role_key_{suffix}_new", + "role_name": f"Update Role {suffix} New", + "role_description": "desc new", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 200 + json = resp.json() + assert json["role_key"] == update_data["role_key"] + assert json["role_name"] == update_data["role_name"] + assert json["role_description"] == update_data["role_description"] + assert json["role_level"] == update_data["role_level"] + + @pytest.mark.asyncio + async def test_update_role_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_id does not exist.""" + suffix = str(random.randint(10000, 99999)) + update_data = { + "role_id": "000000000000000000000000", # 不存在的ObjectId + "role_key": f"notfound_key_{suffix}", + "role_name": f"NotFound Role {suffix}", + "role_description": "desc", + "role_level": 1 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + # create two roles + role1 = await authentication_web.create_role({ + "role_key": f"dupkey1_{suffix}", + "role_name": f"dupkey1_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2 = await authentication_web.create_role({ + "role_key": f"dupkey2_{suffix}", + "role_name": f"dupkey2_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2_id = role2.json()["id"] + # modify role_key + update_data = { + "role_id": role2_id, + "role_key": f"dupkey1_{suffix}", + "role_name": f"dupkey2_{suffix}_new", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + # create two roles + role1 = await authentication_web.create_role({ + "role_key": f"dupname1_{suffix}", + "role_name": f"dupname1_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2 = await authentication_web.create_role({ + "role_key": f"dupname2_{suffix}", + "role_name": f"dupname2_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role2_id = role2.json()["id"] + # modify role name + update_data = { + "role_id": role2_id, + "role_key": f"dupname2_{suffix}_new", + "role_name": f"dupname1_{suffix}", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_key is empty.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"emptykey_{suffix}", + "role_name": f"emptykey_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": "", + "role_name": f"emptykey_{suffix}_new", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_role_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test updating a role fails when role_name is empty.""" + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"emptyname_{suffix}", + "role_name": f"emptyname_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": f"emptyname_{suffix}_new", + "role_name": "", + "role_description": "desc", + "role_level": 2 + } + resp = await authentication_web.update_role(role_data=update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/siginin/__init__.py b/apps/authentication/tests/api_tests/siginin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/siginin/conftest.py b/apps/authentication/tests/api_tests/siginin/conftest.py new file mode 100644 index 0000000..5178696 --- /dev/null +++ b/apps/authentication/tests/api_tests/siginin/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web diff --git a/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py new file mode 100644 index 0000000..29454d1 --- /dev/null +++ b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py @@ -0,0 +1,22 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +class TestSignInWithEmailAndPassword: + + @pytest.mark.asyncio + async def test_sign_in_with_email_and_password(self, authentication_web: AuthenticationWeb): + response = authentication_web.do_login() + assert response.status_code == 200 + json = response.json() + assert json["access_token"] is not None, "access_token should not be None" + assert json["refresh_token"] is not None, "refresh_token should not be None" + assert json["expires_in"] is not None, "expires_in should not be None" + assert json["identity"] is not None, "identity should not be None" + assert json["role_names"] is not None, "role_names should not be None" + assert json["user_permissions"] is not None, "user_permissions should not be None" + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/apps/authentication/tests/base/__init__.py b/apps/authentication/tests/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/base/authentication_web.py b/apps/authentication/tests/base/authentication_web.py new file mode 100644 index 0000000..4ce5d01 --- /dev/null +++ b/apps/authentication/tests/base/authentication_web.py @@ -0,0 +1,64 @@ +import httpx +from typing import Optional +from tests.base.config import USER_EMAIL, USER_PASSWORD, BASE_URL + + +class AuthenticationWeb: + def __init__(self, user_email: str = USER_EMAIL, password: str = USER_PASSWORD, base_url: str = BASE_URL): + self.user_email = user_email + self.password = password + self.base_url = base_url + self.token: Optional[str] = None + + def login(self): + """Login and store JWT token""" + with httpx.Client(base_url=self.base_url) as client: + resp = self.do_login(self.user_email, self.password) + self.token = resp.json()["access_token"] + return resp + + def do_login(self, user_email: str = USER_EMAIL, password: str = USER_PASSWORD): + """Login and store JWT token""" + with httpx.Client(base_url=self.base_url) as client: + resp = client.post("/api/auth/signin/signin-with-email-and-password", json={ + "email": user_email, + "password": password + }) + return resp + + async def request(self, method: str, url: str, **kwargs): + """Send authenticated request""" + headers = kwargs.pop("headers", {}) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + async with httpx.AsyncClient(base_url=self.base_url) as client: + resp = await client.request(method, url, headers=headers, **kwargs) + return resp + + async def create_role(self, role_data: dict): + """Create a new role via API""" + return await self.request("POST", "/api/auth/role/create", json=role_data) + + async def delete_role(self, role_data: dict): + """Delete role via API""" + return await self.request("POST", "/api/auth/role/delete", json=role_data) + + async def update_role(self, role_data: dict): + """Update role via API""" + return await self.request("POST", "/api/auth/role/update", json=role_data) + + async def query_roles(self, params: dict = None): + """Query roles via API""" + if params is None: + params = {} + return await self.request("POST", "/api/auth/role/query", json=params) + + async def create_permission(self, perm_data: dict): + """Create a new permission via API""" + return await self.request("POST", "/api/auth/permission/create", json=perm_data) + + async def query_permissions(self, params: dict = None): + """Query permissions via API""" + return await self.request("GET", "/api/auth/permission/query", params=params) + + diff --git a/apps/authentication/tests/base/config.py b/apps/authentication/tests/base/config.py new file mode 100644 index 0000000..26b5455 --- /dev/null +++ b/apps/authentication/tests/base/config.py @@ -0,0 +1,3 @@ +USER_EMAIL = "icecheng@mathmast.com" +USER_PASSWORD = "@Cwb1535145760" +BASE_URL = "http://localhost:8103" \ No newline at end of file diff --git a/apps/authentication/tests/conftest.py b/apps/authentication/tests/conftest.py new file mode 100644 index 0000000..5178696 --- /dev/null +++ b/apps/authentication/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web From beba0f5fe55c1e387349eed9da34616428603661 Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 09:58:38 +0800 Subject: [PATCH 09/19] feat(role_management): add api test case for permission api --- .../tests/api_tests/permission/__init__.py | 0 .../tests/api_tests/permission/conftest.py | 10 ++ .../permission/test_create_permission.py | 104 +++++++++++ .../permission/test_delete_permission.py | 41 +++++ .../permission/test_query_permission.py | 57 ++++++ .../permission/test_update_permission.py | 150 ++++++++++++++++ .../api_tests/role/test_assign_permissions.py | 163 ++++++++++++++++++ .../tests/api_tests/role/test_delete_role.py | 12 ++ .../tests/api_tests/role/test_update_role.py | 20 +++ .../tests/api_tests/siginin/config.py | 2 + .../test_signin_with_email_and_password.py | 9 + .../tests/base/authentication_web.py | 16 +- 12 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 apps/authentication/tests/api_tests/permission/__init__.py create mode 100644 apps/authentication/tests/api_tests/permission/conftest.py create mode 100644 apps/authentication/tests/api_tests/permission/test_create_permission.py create mode 100644 apps/authentication/tests/api_tests/permission/test_delete_permission.py create mode 100644 apps/authentication/tests/api_tests/permission/test_query_permission.py create mode 100644 apps/authentication/tests/api_tests/permission/test_update_permission.py create mode 100644 apps/authentication/tests/api_tests/role/test_assign_permissions.py create mode 100644 apps/authentication/tests/api_tests/siginin/config.py diff --git a/apps/authentication/tests/api_tests/permission/__init__.py b/apps/authentication/tests/api_tests/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/permission/conftest.py b/apps/authentication/tests/api_tests/permission/conftest.py new file mode 100644 index 0000000..f2aad5a --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture +def authentication_web()->AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web diff --git a/apps/authentication/tests/api_tests/permission/test_create_permission.py b/apps/authentication/tests/api_tests/permission/test_create_permission.py new file mode 100644 index 0000000..783a341 --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/test_create_permission.py @@ -0,0 +1,104 @@ +import pytest +import random +from tests.base.authentication_web import AuthenticationWeb + + +class TestCreatePermission: + @pytest.mark.asyncio + async def test_create_permission_success(self, authentication_web: AuthenticationWeb): + """Test creating a permission successfully with valid and unique permission_key and permission_name.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_success_{suffix}", + "permission_name": f"Test Permission Success {suffix}", + "description": "Permission for testing success" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 200 + json = response.json() + assert json["permission_key"] == perm_data["permission_key"] + assert json["permission_name"] == perm_data["permission_name"] + assert json["description"] == perm_data["description"] + assert json["id"] is not None + assert json["created_at"] is not None + assert json["updated_at"] is not None + + @pytest.mark.asyncio + async def test_create_permission_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_dup_{suffix}", + "permission_name": f"Test Permission DupKey {suffix}", + "description": "desc" + } + await authentication_web.create_permission(perm_data) + perm_data2 = { + "permission_key": f"test_perm_key_dup_{suffix}", + "permission_name": f"Test Permission DupKey2 {suffix}", + "description": "desc2" + } + response = await authentication_web.create_permission(perm_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_dupname1_{suffix}", + "permission_name": f"Test Permission DupName {suffix}", + "description": "desc" + } + await authentication_web.create_permission(perm_data) + perm_data2 = { + "permission_key": f"test_perm_key_dupname2_{suffix}", + "permission_name": f"Test Permission DupName {suffix}", + "description": "desc2" + } + response = await authentication_web.create_permission(perm_data2) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_key is empty.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": "", + "permission_name": f"Test Permission EmptyKey {suffix}", + "description": "desc" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test creating a permission fails when permission_name is empty.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_emptyname_{suffix}", + "permission_name": "", + "description": "desc" + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 422 or response.status_code == 400 + + @pytest.mark.asyncio + async def test_create_permission_success_empty_description(self, authentication_web: AuthenticationWeb): + """Test creating a permission successfully when description is None (optional field).""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_emptydesc_{suffix}", + "permission_name": f"Test Permission EmptyDesc {suffix}", + "description": None + } + response = await authentication_web.create_permission(perm_data) + assert response.status_code == 200 + json = response.json() + assert json["permission_key"] == perm_data["permission_key"] + assert json["permission_name"] == perm_data["permission_name"] + assert json["description"] is None or json["description"] == "" + + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/permission/test_delete_permission.py b/apps/authentication/tests/api_tests/permission/test_delete_permission.py new file mode 100644 index 0000000..73ad109 --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/test_delete_permission.py @@ -0,0 +1,41 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestDeletePermission: + @pytest.mark.asyncio + async def test_delete_permission_success(self, authentication_web: AuthenticationWeb): + """Test deleting a permission successfully.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_{suffix}", + "permission_name": f"delperm_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_permission_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test deleting a permission fails when permission_id does not exist.""" + resp = await authentication_web.delete_permission({"permission_id": "000000000000000000000000"}) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_delete_default_permission_fail(self, authentication_web: AuthenticationWeb): + """Test deleting a default permission fails. Default permission cannot be deleted.""" + # Query a default role + resp = await authentication_web.query_permissions( + params={"page": 1, "page_size": 2, "permission_key": DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key}) + json = resp.json() + default_permission_id = json["items"][0]["id"] + resp = await authentication_web.delete_permission(perm_data={"permission_id": default_permission_id}) + assert resp.status_code == 422 or resp.status_code == 400 + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/permission/test_query_permission.py b/apps/authentication/tests/api_tests/permission/test_query_permission.py new file mode 100644 index 0000000..2f8f4cc --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/test_query_permission.py @@ -0,0 +1,57 @@ +import random +import pytest +from tests.base.authentication_web import AuthenticationWeb + + +class TestQueryPermission: + @pytest.mark.asyncio + async def test_query_all_permissions(self, authentication_web: AuthenticationWeb): + """Test querying all permissions returns a list.""" + resp = await authentication_web.query_permissions({}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + + @pytest.mark.asyncio + async def test_query_permissions_by_key(self, authentication_web: AuthenticationWeb): + """Test querying permissions by permission_key with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_permission({ + "permission_key": f"querykey_{suffix}", + "permission_name": f"querykey_{suffix}", + "description": "desc" + }) + resp = await authentication_web.query_permissions({"permission_key": f"querykey_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"querykey_{suffix}" in item["permission_key"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_permissions_by_name(self, authentication_web: AuthenticationWeb): + """Test querying permissions by permission_name with fuzzy search.""" + suffix = str(random.randint(10000, 99999)) + await authentication_web.create_permission({ + "permission_key": f"queryname_{suffix}", + "permission_name": f"queryname_{suffix}", + "description": "desc" + }) + resp = await authentication_web.query_permissions({"permission_name": f"queryname_{suffix}"}) + assert resp.status_code == 200 + json = resp.json() + assert any(f"queryname_{suffix}" in item["permission_name"] for item in json["items"]) + + @pytest.mark.asyncio + async def test_query_permissions_pagination(self, authentication_web: AuthenticationWeb): + """Test querying permissions with pagination.""" + resp = await authentication_web.query_permissions({"page": 1, "page_size": 2}) + assert resp.status_code == 200 + json = resp.json() + assert "items" in json + assert "total" in json + assert "page" in json + assert "page_size" in json + + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/permission/test_update_permission.py b/apps/authentication/tests/api_tests/permission/test_update_permission.py new file mode 100644 index 0000000..9027afd --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/test_update_permission.py @@ -0,0 +1,150 @@ +import pytest +import random + +from backend.models.permission.constants import DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestUpdatePermission: + @pytest.mark.asyncio + async def test_update_permission_success(self, authentication_web: AuthenticationWeb): + """Test updating a permission successfully with valid and unique fields.""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"update_perm_key_{suffix}", + "permission_name": f"Update Permission {suffix}", + "description": "desc" + } + create_resp = await authentication_web.create_permission(perm_data) + perm_id = create_resp.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"update_perm_key_{suffix}_new", + "permission_name": f"Update Permission {suffix} New", + "description": "desc new" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 200 + json = resp.json() + assert json["permission_key"] == update_data["permission_key"] + assert json["permission_name"] == update_data["permission_name"] + assert json["description"] == update_data["description"] + + @pytest.mark.asyncio + async def test_update_permission_fail_not_found(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_id does not exist.""" + suffix = str(random.randint(10000, 99999)) + update_data = { + "permission_id": "000000000000000000000000", + "permission_key": f"notfound_key_{suffix}", + "permission_name": f"NotFound Permission {suffix}", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_duplicate_key(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_key is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm1 = await authentication_web.create_permission({ + "permission_key": f"dupkey1_{suffix}", + "permission_name": f"dupkey1_{suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"dupkey2_{suffix}", + "permission_name": f"dupkey2_{suffix}", + "description": "desc" + }) + perm2_id = perm2.json()["id"] + update_data = { + "permission_id": perm2_id, + "permission_key": f"dupkey1_{suffix}", + "permission_name": f"dupkey2_{suffix}_new", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_duplicate_name(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_name is duplicated.""" + suffix = str(random.randint(10000, 99999)) + perm1 = await authentication_web.create_permission({ + "permission_key": f"dupname1_{suffix}", + "permission_name": f"dupname1_{suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"dupname2_{suffix}", + "permission_name": f"dupname2_{suffix}", + "description": "desc" + }) + perm2_id = perm2.json()["id"] + update_data = { + "permission_id": perm2_id, + "permission_key": f"dupname2_{suffix}_new", + "permission_name": f"dupname1_{suffix}", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_empty_key(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_key is empty.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"emptykey_{suffix}", + "permission_name": f"emptykey_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": "", + "permission_name": f"emptykey_{suffix}_new", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_permission_fail_empty_name(self, authentication_web: AuthenticationWeb): + """Test updating a permission fails when permission_name is empty.""" + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"emptyname_{suffix}", + "permission_name": f"emptyname_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"emptyname_{suffix}_new", + "permission_name": "", + "description": "desc" + } + resp = await authentication_web.update_permission(update_data) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_default_permission_fail(self, authentication_web: AuthenticationWeb): + """Test updating a default permission fails. Default permission cannot be updated.""" + suffix = str(random.randint(10000, 99999)) + # Query a default role + resp = await authentication_web.query_permissions( + params={"page": 1, "page_size": 2, "permission_key": DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key}) + json = resp.json() + default_permission = json["items"][0] + resp = await authentication_web.update_permission(perm_data={ + "permission_id": default_permission["id"], + "permission_key": f"{default_permission['permission_key']}_{suffix}_update", + "permission_name": f"{default_permission['permission_name']}_{suffix}_update", + "description": "desc", + }) + assert resp.status_code == 422 or resp.status_code == 400 + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/role/test_assign_permissions.py b/apps/authentication/tests/api_tests/role/test_assign_permissions.py new file mode 100644 index 0000000..3deec5e --- /dev/null +++ b/apps/authentication/tests/api_tests/role/test_assign_permissions.py @@ -0,0 +1,163 @@ +import pytest +import random +from typing import List +from backend.models.permission.constants import DefaultRoleEnum, DefaultPermissionEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestAssignPermissionsToRole: + @pytest.mark.asyncio + async def test_assign_permissions_success(self, authentication_web: AuthenticationWeb): + """Test assigning permissions to a role successfully.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_{suffix}", + "role_name": f"AssignPerm Role {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Create two permissions + perm1 = await authentication_web.create_permission({ + "permission_key": f"assignperm_key1_{suffix}", + "permission_name": f"AssignPerm Permission1 {suffix}", + "description": "desc" + }) + perm2 = await authentication_web.create_permission({ + "permission_key": f"assignperm_key2_{suffix}", + "permission_name": f"AssignPerm Permission2 {suffix}", + "description": "desc" + }) + perm_ids = [perm1.json()["id"], perm2.json()["id"]] + # Assign permissions + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": perm_ids + }) + assert resp.status_code == 200 + json = resp.json() + assert set(json["permission_ids"]) == set(perm_ids) + + @pytest.mark.asyncio + async def test_assign_permissions_fail_role_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when role_id does not exist.""" + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_nf_{suffix}", + "permission_name": f"AssignPerm PermissionNF {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": "000000000000000000000000", + "permission_ids": [perm_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_permission_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when a permission_id does not exist.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_nf_{suffix}", + "role_name": f"AssignPerm RoleNF {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": ["000000000000000000000000"] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_empty_permission_ids(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when permission_ids is empty.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_empty_{suffix}", + "role_name": f"AssignPerm RoleEmpty {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": [] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_permissions_fail_empty_role_id(self, authentication_web: AuthenticationWeb): + """Test assigning permissions fails when role_id is empty.""" + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_emptyrole_{suffix}", + "permission_name": f"AssignPerm PermissionEmptyRole {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + resp = await authentication_web.assign_permissions_to_role({ + "role_id": "", + "permission_ids": [perm_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 or resp.status_code == 500 + + @pytest.mark.asyncio + async def test_assign_permissions_remove_duplicates(self, authentication_web: AuthenticationWeb): + """Test assigning permissions with duplicate permission_ids removes duplicates.""" + # Create a role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignperm_role_dup_{suffix}", + "role_name": f"AssignPerm RoleDup {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Create a permission + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_dup_{suffix}", + "permission_name": f"AssignPerm PermissionDup {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Assign duplicate permission_ids + resp = await authentication_web.assign_permissions_to_role({ + "role_id": role_id, + "permission_ids": [perm_id, perm_id, perm_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["permission_ids"] == [perm_id] + + @pytest.mark.asyncio + async def test_assign_permissions_to_default_role(self, authentication_web: AuthenticationWeb): + """Test assigning permissions to a default role (should succeed if not restricted).""" + # Query default admin role + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role_id = json["items"][0]["id"] + # Create a permission + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"assignperm_key_default_{suffix}", + "permission_name": f"AssignPerm PermissionDefault {suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Try to assign permission to default role + resp = await authentication_web.assign_permissions_to_role({ + "role_id": default_role_id, + "permission_ids": [perm_id, *json["items"][0]["permission_ids"]] + }) + assert resp.status_code in [200] + +if __name__ == '__main__': + pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/role/test_delete_role.py b/apps/authentication/tests/api_tests/role/test_delete_role.py index b6f6918..8d0b43e 100644 --- a/apps/authentication/tests/api_tests/role/test_delete_role.py +++ b/apps/authentication/tests/api_tests/role/test_delete_role.py @@ -1,5 +1,7 @@ import pytest import random + +from backend.models.permission.constants import DefaultRole, DefaultRoleEnum from tests.base.authentication_web import AuthenticationWeb @@ -26,6 +28,16 @@ class TestDeleteRole: resp = await authentication_web.delete_role(role_data={"role_id": "000000000000000000000000"}) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_delete_default_role_fail(self, authentication_web: AuthenticationWeb): + """Test deleting a default role fails. Default role cannot be deleted.""" + # Query a default role + resp = await authentication_web.query_roles( + params={"page": 1, "page_size": 2, "role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role_id = json["items"][0]["id"] + resp = await authentication_web.delete_role(role_data={"role_id": default_role_id}) + assert resp.status_code == 422 or resp.status_code == 400 if __name__ == '__main__': pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/role/test_update_role.py b/apps/authentication/tests/api_tests/role/test_update_role.py index 848d7e0..f483ed3 100644 --- a/apps/authentication/tests/api_tests/role/test_update_role.py +++ b/apps/authentication/tests/api_tests/role/test_update_role.py @@ -1,5 +1,7 @@ import pytest import random + +from backend.models.permission.constants import DefaultRoleEnum from tests.base.authentication_web import AuthenticationWeb @@ -148,6 +150,24 @@ class TestUpdateRole: resp = await authentication_web.update_role(role_data=update_data) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_update_default_role_fail(self, authentication_web: AuthenticationWeb): + """Test updating a default role fails. Default role cannot be updated.""" + suffix = str(random.randint(10000, 99999)) + # Query a default role + resp = await authentication_web.query_roles( + params={"page": 1, "page_size": 2, "role_key": DefaultRoleEnum.ADMIN.value.role_key}) + json = resp.json() + default_role = json["items"][0] + resp = await authentication_web.update_role(role_data={ + "role_id": default_role["id"], + "role_key": f"{default_role['role_key']}_{suffix}_update", + "role_name": f"{default_role['role_name']}_{suffix}_update", + "role_description": "desc", + "role_level": 2 + }) + assert resp.status_code == 422 or resp.status_code == 400 + if __name__ == '__main__': pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/siginin/config.py b/apps/authentication/tests/api_tests/siginin/config.py new file mode 100644 index 0000000..806c60c --- /dev/null +++ b/apps/authentication/tests/api_tests/siginin/config.py @@ -0,0 +1,2 @@ +JWT_SECRET_KEY = "ea84edf152976b2fcec12b78aa8e45bc26a5cf0ef61bf16f5c317ae33b3fd8b0" +JWT_ALGORITHM = "HS256" diff --git a/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py index 29454d1..34a5322 100644 --- a/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py +++ b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py @@ -1,5 +1,8 @@ +import jwt import pytest +from common.config.app_settings import app_settings +from tests.api_tests.siginin import config from tests.base.authentication_web import AuthenticationWeb @@ -17,6 +20,12 @@ class TestSignInWithEmailAndPassword: assert json["role_names"] is not None, "role_names should not be None" assert json["user_permissions"] is not None, "user_permissions should not be None" + payload = jwt.decode(json["access_token"], config.JWT_SECRET_KEY, algorithms=[config.JWT_ALGORITHM]) + assert payload["subject"] is not None, "subject should not be None" + assert payload["subject"]["id"] is not None, "subject.id should not be None" + assert payload["subject"]["role_names"] is not None, "subject.role_names should not be None" + assert payload["subject"]["user_permissions"] is not None, "subject.user_permissions should not be None" + print(payload) if __name__ == '__main__': pytest.main([__file__]) diff --git a/apps/authentication/tests/base/authentication_web.py b/apps/authentication/tests/base/authentication_web.py index 4ce5d01..e0da081 100644 --- a/apps/authentication/tests/base/authentication_web.py +++ b/apps/authentication/tests/base/authentication_web.py @@ -57,8 +57,22 @@ class AuthenticationWeb: """Create a new permission via API""" return await self.request("POST", "/api/auth/permission/create", json=perm_data) + async def delete_permission(self, perm_data: dict): + """Delete a permission via API""" + return await self.request("POST", "/api/auth/permission/delete", json=perm_data) + + async def update_permission(self, perm_data: dict): + """Update a permission via API""" + return await self.request("POST", "/api/auth/permission/update", json=perm_data) + async def query_permissions(self, params: dict = None): """Query permissions via API""" - return await self.request("GET", "/api/auth/permission/query", params=params) + if params is None: + params = {} + return await self.request("POST", "/api/auth/permission/query", json=params) + + async def assign_permissions_to_role(self, data: dict): + """Assign permissions to a role via API""" + return await self.request("POST", "/api/auth/role/assign-permissions", json=data) From 9dcd27bc8a558eeccd8dce663808a47434339d8c Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 11:30:16 +0800 Subject: [PATCH 10/19] feat(role_management): Add logic for creating temporary users to apitest. --- .../tests/api_tests/permission/conftest.py | 14 +++- .../permission/test_create_permission.py | 2 +- .../tests/base/authentication_web.py | 73 ++++++++++++++++++ apps/authentication/tests/util/__init__.py | 0 .../tests/util/temporary_email.py | 76 +++++++++++++++++++ 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/authentication/tests/util/__init__.py create mode 100644 apps/authentication/tests/util/temporary_email.py diff --git a/apps/authentication/tests/api_tests/permission/conftest.py b/apps/authentication/tests/api_tests/permission/conftest.py index f2aad5a..fbf1fa7 100644 --- a/apps/authentication/tests/api_tests/permission/conftest.py +++ b/apps/authentication/tests/api_tests/permission/conftest.py @@ -3,8 +3,18 @@ import pytest from tests.base.authentication_web import AuthenticationWeb -@pytest.fixture -def authentication_web()->AuthenticationWeb: +@pytest.fixture(scope="session") +def authentication_web() -> AuthenticationWeb: authentication_web = AuthenticationWeb() authentication_web.login() return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.login() + return authentication_web diff --git a/apps/authentication/tests/api_tests/permission/test_create_permission.py b/apps/authentication/tests/api_tests/permission/test_create_permission.py index 783a341..08a7e03 100644 --- a/apps/authentication/tests/api_tests/permission/test_create_permission.py +++ b/apps/authentication/tests/api_tests/permission/test_create_permission.py @@ -101,4 +101,4 @@ class TestCreatePermission: if __name__ == '__main__': - pytest.main([__file__]) \ No newline at end of file + pytest.main([__file__]) diff --git a/apps/authentication/tests/base/authentication_web.py b/apps/authentication/tests/base/authentication_web.py index e0da081..725aa48 100644 --- a/apps/authentication/tests/base/authentication_web.py +++ b/apps/authentication/tests/base/authentication_web.py @@ -1,6 +1,9 @@ +import asyncio + import httpx from typing import Optional from tests.base.config import USER_EMAIL, USER_PASSWORD, BASE_URL +from tests.util.temporary_email import * class AuthenticationWeb: @@ -10,6 +13,63 @@ class AuthenticationWeb: self.base_url = base_url self.token: Optional[str] = None + def create_temporary_user(self) -> dict[str, str]: + """Create a temporary user.""" + # generate temporary user email + email = generate_email() + print("temporary user email:", email) + # call try-signin-with-email api + response1 = self.try_signin_with_email(params={"email": email, "host": self.base_url}) + print("try_signin_with_email", response1.json()) + # query auth code + auth_code = get_auth_code(email) + print("temporary user auth code:", auth_code) + response2 = self.signin_with_email_and_code( + params={"email": email, "code": auth_code, "host": self.base_url}) + print("signin_with_email_and_code", response2.json()) + access_token = response2.json()["access_token"] + + response3 = self.update_new_user_flid(token=access_token, params={'flid': response2.json()['flid']}) + print("update_new_user_flid", response3.json()) + + password = "Kdwy12#$" + # set password + response4 = self.update_user_password(token=access_token, params={ + 'password': password, + 'password2': password + }) + print("update_user_password", response4.json()) + return { + "email": email, + "password": password, + } + + def update_new_user_flid(self, params: dict, token: str = None): + """Update the user's FLID.""" + if token is None: + token = self.token + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(base_url=self.base_url) as client: + resp = client.request("POST", "/api/auth/signin/update-new-user-flid", headers=headers, json=params) + return resp + + def update_user_password(self, params: dict, token: str = None): + """Update the user's password.""" + if token is None: + token = self.token + headers = {"Authorization": f"Bearer {token}"} + with httpx.Client(base_url=self.base_url) as client: + resp = client.request("POST", "/api/auth/signin/update-user-password", headers=headers, json=params) + return resp + + def try_signin_with_email(self, params): + """try signin with email.""" + return self.request_sync("POST", "/api/auth/signin/try-signin-with-email", json=params) + + def signin_with_email_and_code(self, params): + """try signin with email and code.""" + return self.request_sync("POST", "/api/auth/signin/signin-with-email-and-code", json=params) + def login(self): """Login and store JWT token""" with httpx.Client(base_url=self.base_url) as client: @@ -26,6 +86,15 @@ class AuthenticationWeb: }) return resp + def request_sync(self, method: str, url: str, **kwargs): + """Send authenticated request""" + headers = kwargs.pop("headers", {}) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + with httpx.Client(base_url=self.base_url) as client: + resp = client.request(method, url, headers=headers, **kwargs) + return resp + async def request(self, method: str, url: str, **kwargs): """Send authenticated request""" headers = kwargs.pop("headers", {}) @@ -76,3 +145,7 @@ class AuthenticationWeb: return await self.request("POST", "/api/auth/role/assign-permissions", json=data) +if __name__ == '__main__': + authentication = AuthenticationWeb() + user = authentication.create_temporary_user() + print(user) diff --git a/apps/authentication/tests/util/__init__.py b/apps/authentication/tests/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/util/temporary_email.py b/apps/authentication/tests/util/temporary_email.py new file mode 100644 index 0000000..79d8e47 --- /dev/null +++ b/apps/authentication/tests/util/temporary_email.py @@ -0,0 +1,76 @@ +import re +from time import sleep + +import requests +from faker import Faker + +TEMPORARY_EMAIL_DOMAIN = "https://api.mail.cx/api/v1" + + +def generate_email() -> str: + fake = Faker('en_US') + while True: + name = fake.name().replace(' ', '_') + if len(name) <= 10: + break + return f"{name}@nqmo.com" + + +def get_auth_email_token() -> str: + url = TEMPORARY_EMAIL_DOMAIN + "/auth/authorize_token" + headers = { + 'accept': 'application/json', + 'Authorization': 'Bearer undefined', + } + response = requests.post(url, headers=headers) + return str(response.json()) + + +def get_mail_id(address, token): + url = TEMPORARY_EMAIL_DOMAIN + f"/mailbox/{address}" + headers = { + 'accept': 'application/json', + 'Authorization': f'Bearer {token}', + } + response = requests.get(url, headers=headers) + body = response.json() + return body[0]['id'] if len(body) and len(body[0]['id']) > 0 else None + + +def get_auth_code(email): + # get token + token = get_auth_email_token() + print(f"token: {token}") + + # Waiting for verification code email + id_ = None + for _ in range(30): + id_ = get_mail_id(email, token) + if id_ is not None: + break + sleep(1) + if id_ is None: + raise Exception(f"Could not get auth code for {email}") + # get code + url = TEMPORARY_EMAIL_DOMAIN + f'/mailbox/{email}/{id_}' + headers = { + 'accept': 'application/json', + 'Authorization': f'Bearer {token}', + } + + response = requests.get(url, headers=headers) + print(response.json()) + + # Regular matching captcha, here the regular expression matching captcha is changed to its own + captcha = re.search(r'The auth code is:\s+(\d+)', response.json()['body']['html']) + if captcha: + print("code:", captcha.group(1)) + else: + print("Unable to find verification code") + return captcha.group(1) + + +if __name__ == '__main__': + email = generate_email() + code = get_auth_code(email) + print(code) From 6b6b52a599fa210c9e1668cfb92aa2426225423f Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 11:52:41 +0800 Subject: [PATCH 11/19] feat(role_management): Add apitest for test_assign_roles --- .../tests/api_tests/user/__init__.py | 0 .../tests/api_tests/user/conftest.py | 21 ++++ .../tests/api_tests/user/test_assign_roles.py | 100 ++++++++++++++++++ .../tests/base/authentication_web.py | 6 ++ 4 files changed, 127 insertions(+) create mode 100644 apps/authentication/tests/api_tests/user/__init__.py create mode 100644 apps/authentication/tests/api_tests/user/conftest.py create mode 100644 apps/authentication/tests/api_tests/user/test_assign_roles.py diff --git a/apps/authentication/tests/api_tests/user/__init__.py b/apps/authentication/tests/api_tests/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/user/conftest.py b/apps/authentication/tests/api_tests/user/conftest.py new file mode 100644 index 0000000..b96c630 --- /dev/null +++ b/apps/authentication/tests/api_tests/user/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from tests.base.authentication_web import AuthenticationWeb + + +@pytest.fixture(scope="session") +def authentication_web() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + authentication_web.login() + return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] + authentication_web.login() + return authentication_web diff --git a/apps/authentication/tests/api_tests/user/test_assign_roles.py b/apps/authentication/tests/api_tests/user/test_assign_roles.py new file mode 100644 index 0000000..18c7e30 --- /dev/null +++ b/apps/authentication/tests/api_tests/user/test_assign_roles.py @@ -0,0 +1,100 @@ +import pytest +import random +from backend.models.permission.constants import DefaultRoleEnum +from tests.base.authentication_web import AuthenticationWeb + + +class TestAssignRolesToUser: + @pytest.mark.asyncio + async def test_assign_roles_success_by_admin(self, authentication_web: AuthenticationWeb): + """Test assigning roles to a user successfully by admin user.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Create a new role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignrole_role_{suffix}", + "role_name": f"AssignRole Role {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Assign role to user + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [role_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["user_id"] == temp_user["user_id"] + assert json["role_ids"] == [role_id] + + @pytest.mark.asyncio + async def test_assign_roles_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test assigning roles to a user fails by non-admin user (no permission).""" + # Create another temporary user + temp_user = authentication_web_of_temp_user1.create_temporary_user() + # Query default admin role + resp = await authentication_web_of_temp_user1.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + # Try to assign admin role to another user + resp = await authentication_web_of_temp_user1.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [admin_role_id] + }) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_assign_roles_fail_role_not_found(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when role_id does not exist.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Try to assign non-existent role + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": ["000000000000000000000000"] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_fail_empty_role_ids(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when role_ids is empty.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_fail_empty_user_id(self, authentication_web: AuthenticationWeb): + """Test assigning roles fails when user_id is empty.""" + # Query default admin role + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + resp = await authentication_web.assign_roles_to_user({ + "user_id": "", "role_ids": [admin_role_id] + }) + assert resp.status_code == 422 or resp.status_code == 400 + + @pytest.mark.asyncio + async def test_assign_roles_remove_duplicates(self, authentication_web: AuthenticationWeb): + """Test assigning roles with duplicate role_ids removes duplicates.""" + # Create a temporary user + temp_user = authentication_web.create_temporary_user() + # Create a new role + suffix = str(random.randint(10000, 99999)) + role_resp = await authentication_web.create_role({ + "role_key": f"assignrole_role_dup_{suffix}", + "role_name": f"AssignRole RoleDup {suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role_resp.json()["id"] + # Assign duplicate role_ids + resp = await authentication_web.assign_roles_to_user({ + "user_id": temp_user["user_id"], "role_ids": [role_id, role_id, role_id] + }) + assert resp.status_code == 200 + json = resp.json() + assert json["role_ids"] == [role_id] + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/apps/authentication/tests/base/authentication_web.py b/apps/authentication/tests/base/authentication_web.py index 725aa48..e3cfc9a 100644 --- a/apps/authentication/tests/base/authentication_web.py +++ b/apps/authentication/tests/base/authentication_web.py @@ -10,6 +10,7 @@ class AuthenticationWeb: def __init__(self, user_email: str = USER_EMAIL, password: str = USER_PASSWORD, base_url: str = BASE_URL): self.user_email = user_email self.password = password + self.user_id = None self.base_url = base_url self.token: Optional[str] = None @@ -42,6 +43,7 @@ class AuthenticationWeb: return { "email": email, "password": password, + "user_id": response2.json()["identity"] } def update_new_user_flid(self, params: dict, token: str = None): @@ -144,6 +146,10 @@ class AuthenticationWeb: """Assign permissions to a role via API""" return await self.request("POST", "/api/auth/role/assign-permissions", json=data) + async def assign_roles_to_user(self, data: dict): + """Assign roles to a user via API""" + return await self.request("POST", "/api/auth/user/assign-roles", json=data) + if __name__ == '__main__': authentication = AuthenticationWeb() From 5be8403c751e03b37c19550e823d6e1a8608138c Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 12:14:58 +0800 Subject: [PATCH 12/19] feat(role_management): update apitest for role management --- .../tests/api_tests/permission/conftest.py | 1 + .../tests/api_tests/role/conftest.py | 13 +++- .../tests/api_tests/role/test_create_role.py | 42 +++++++++++++ .../tests/api_tests/role/test_delete_role.py | 48 +++++++++++++++ .../tests/api_tests/role/test_update_role.py | 60 +++++++++++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/apps/authentication/tests/api_tests/permission/conftest.py b/apps/authentication/tests/api_tests/permission/conftest.py index fbf1fa7..b96c630 100644 --- a/apps/authentication/tests/api_tests/permission/conftest.py +++ b/apps/authentication/tests/api_tests/permission/conftest.py @@ -16,5 +16,6 @@ def authentication_web_of_temp_user1() -> AuthenticationWeb: user = authentication_web.create_temporary_user() authentication_web.user_email = user["email"] authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] authentication_web.login() return authentication_web diff --git a/apps/authentication/tests/api_tests/role/conftest.py b/apps/authentication/tests/api_tests/role/conftest.py index f2aad5a..3731f6c 100644 --- a/apps/authentication/tests/api_tests/role/conftest.py +++ b/apps/authentication/tests/api_tests/role/conftest.py @@ -3,8 +3,19 @@ import pytest from tests.base.authentication_web import AuthenticationWeb -@pytest.fixture +@pytest.fixture(scope="session") def authentication_web()->AuthenticationWeb: authentication_web = AuthenticationWeb() authentication_web.login() return authentication_web + + +@pytest.fixture(scope="session") +def authentication_web_of_temp_user1() -> AuthenticationWeb: + authentication_web = AuthenticationWeb() + user = authentication_web.create_temporary_user() + authentication_web.user_email = user["email"] + authentication_web.password = user["password"] + authentication_web.user_id = user["user_id"] + authentication_web.login() + return authentication_web \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/role/test_create_role.py b/apps/authentication/tests/api_tests/role/test_create_role.py index a52db06..8d4c488 100644 --- a/apps/authentication/tests/api_tests/role/test_create_role.py +++ b/apps/authentication/tests/api_tests/role/test_create_role.py @@ -110,6 +110,48 @@ class TestCreateRole: assert json["role_description"] is None or json["role_description"] == "" assert json["role_level"] == role_data["role_level"] + @pytest.mark.asyncio + async def test_create_role_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test creating a role fails by non-admin user (no permission).""" + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_nonadmin_{suffix}", + "role_name": f"Test Role NonAdmin {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await authentication_web_of_temp_user1.create_role(role_data) + assert response.status_code == 403 or response.status_code == 401 + + @pytest.mark.asyncio + async def test_create_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test creating a role succeeds after granting admin role to a temporary user and re-login.""" + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + response1 = await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to create role + suffix = str(random.randint(10000, 99999)) + role_data = { + "role_key": f"test_role_key_tempadmin_{suffix}", + "role_name": f"Test Role TempAdmin {suffix}", + "role_description": "desc", + "role_level": 1 + } + response = await temp_authentication_web.create_role(role_data) + assert response.status_code == 200 + diff --git a/apps/authentication/tests/api_tests/role/test_delete_role.py b/apps/authentication/tests/api_tests/role/test_delete_role.py index 8d0b43e..a94ce2c 100644 --- a/apps/authentication/tests/api_tests/role/test_delete_role.py +++ b/apps/authentication/tests/api_tests/role/test_delete_role.py @@ -39,5 +39,53 @@ class TestDeleteRole: resp = await authentication_web.delete_role(role_data={"role_id": default_role_id}) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_delete_role_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test deleting a role fails by non-admin user (no permission).""" + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_nonadmin_{suffix}", + "role_name": f"delrole_nonadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Try to delete as temp user + resp = await authentication_web_of_temp_user1.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_delete_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test deleting a role succeeds after granting admin role to a temporary user and re-login.""" + + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"delrole_tempadmin_{suffix}", + "role_name": f"delrole_tempadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": DefaultRoleEnum.ADMIN.value.role_key}) + admin_role_id = resp.json()["items"][0]["id"] + response1 = await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to delete as temp user + resp = await temp_authentication_web.delete_role(role_data={"role_id": role_id}) + assert resp.status_code == 200 + if __name__ == '__main__': pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/role/test_update_role.py b/apps/authentication/tests/api_tests/role/test_update_role.py index f483ed3..de2421f 100644 --- a/apps/authentication/tests/api_tests/role/test_update_role.py +++ b/apps/authentication/tests/api_tests/role/test_update_role.py @@ -168,6 +168,66 @@ class TestUpdateRole: }) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_update_role_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test updating a role fails by non-admin user (no permission).""" + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"updaterole_nonadmin_{suffix}", + "role_name": f"updaterole_nonadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + update_data = { + "role_id": role_id, + "role_key": f"updaterole_nonadmin_{suffix}_new", + "role_name": f"updaterole_nonadmin_{suffix}_new", + "role_description": "desc new", + "role_level": 2 + } + resp = await authentication_web_of_temp_user1.update_role(role_data=update_data) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_update_role_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test updating a role succeeds after granting admin role to a temporary user and re-login.""" + # Create a temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + + # Create a role as admin + suffix = str(random.randint(10000, 99999)) + role = await authentication_web.create_role({ + "role_key": f"updaterole_tempadmin_{suffix}", + "role_name": f"updaterole_tempadmin_{suffix}", + "role_description": "desc", + "role_level": 1 + }) + role_id = role.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to update as temp user + update_data = { + "role_id": role_id, + "role_key": f"updaterole_tempadmin_{suffix}_new", + "role_name": f"updaterole_tempadmin_{suffix}_new", + "role_description": "desc new", + "role_level": 2 + } + resp = await temp_authentication_web.update_role(role_data=update_data) + assert resp.status_code == 200 + if __name__ == '__main__': pytest.main([__file__]) From 55e0bebbef1bfe462a554ae0a10478d080051657 Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 12:27:26 +0800 Subject: [PATCH 13/19] feat(role_management): update apitest for role management --- .../permission/test_create_permission.py | 39 +++++++++++++ .../permission/test_delete_permission.py | 44 +++++++++++++++ .../permission/test_update_permission.py | 55 +++++++++++++++++++ 3 files changed, 138 insertions(+) diff --git a/apps/authentication/tests/api_tests/permission/test_create_permission.py b/apps/authentication/tests/api_tests/permission/test_create_permission.py index 08a7e03..ab579be 100644 --- a/apps/authentication/tests/api_tests/permission/test_create_permission.py +++ b/apps/authentication/tests/api_tests/permission/test_create_permission.py @@ -99,6 +99,45 @@ class TestCreatePermission: assert json["permission_name"] == perm_data["permission_name"] assert json["description"] is None or json["description"] == "" + @pytest.mark.asyncio + async def test_create_permission_fail_by_non_admin(self, authentication_web_of_temp_user1: AuthenticationWeb): + """Test creating a permission fails by non-admin user (no permission).""" + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_nonadmin_{suffix}", + "permission_name": f"Test Permission NonAdmin {suffix}", + "description": "desc" + } + response = await authentication_web_of_temp_user1.create_permission(perm_data) + assert response.status_code == 403 or response.status_code == 401 + + @pytest.mark.asyncio + async def test_create_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test creating a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to create permission + suffix = str(random.randint(10000, 99999)) + perm_data = { + "permission_key": f"test_perm_key_tempadmin_{suffix}", + "permission_name": f"Test Permission TempAdmin {suffix}", + "description": "desc" + } + response = await temp_authentication_web.create_permission(perm_data) + assert response.status_code == 200 + if __name__ == '__main__': pytest.main([__file__]) diff --git a/apps/authentication/tests/api_tests/permission/test_delete_permission.py b/apps/authentication/tests/api_tests/permission/test_delete_permission.py index 73ad109..6604daf 100644 --- a/apps/authentication/tests/api_tests/permission/test_delete_permission.py +++ b/apps/authentication/tests/api_tests/permission/test_delete_permission.py @@ -37,5 +37,49 @@ class TestDeletePermission: resp = await authentication_web.delete_permission(perm_data={"permission_id": default_permission_id}) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_delete_permission_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test deleting a permission fails by non-admin user (no permission).""" + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_nonadmin_{suffix}", + "permission_name": f"delperm_nonadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Try to delete as temp user + resp = await authentication_web_of_temp_user1.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_delete_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test deleting a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"delperm_tempadmin_{suffix}", + "permission_name": f"delperm_tempadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to delete as temp user + resp = await temp_authentication_web.delete_permission({"permission_id": perm_id}) + assert resp.status_code == 200 + if __name__ == '__main__': pytest.main([__file__]) \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/permission/test_update_permission.py b/apps/authentication/tests/api_tests/permission/test_update_permission.py index 9027afd..4cf1e20 100644 --- a/apps/authentication/tests/api_tests/permission/test_update_permission.py +++ b/apps/authentication/tests/api_tests/permission/test_update_permission.py @@ -146,5 +146,60 @@ class TestUpdatePermission: }) assert resp.status_code == 422 or resp.status_code == 400 + @pytest.mark.asyncio + async def test_update_permission_fail_by_non_admin(self, authentication_web: AuthenticationWeb, authentication_web_of_temp_user1: AuthenticationWeb): + """Test updating a permission fails by non-admin user (no permission).""" + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"updateperm_nonadmin_{suffix}", + "permission_name": f"updateperm_nonadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + update_data = { + "permission_id": perm_id, + "permission_key": f"updateperm_nonadmin_{suffix}_new", + "permission_name": f"updateperm_nonadmin_{suffix}_new", + "description": "desc new" + } + resp = await authentication_web_of_temp_user1.update_permission(update_data) + assert resp.status_code == 403 or resp.status_code == 401 + + @pytest.mark.asyncio + async def test_update_permission_success_after_grant_admin(self, authentication_web: AuthenticationWeb): + """Test updating a permission succeeds after granting admin role to a new temporary user and re-login.""" + # Create a new temp user + user = authentication_web.create_temporary_user() + temp_authentication_web = AuthenticationWeb(user_email=user["email"], password=user["password"]) + temp_authentication_web.user_id = user["user_id"] + temp_authentication_web.login() + # Create a permission as admin + suffix = str(random.randint(10000, 99999)) + perm = await authentication_web.create_permission({ + "permission_key": f"updateperm_tempadmin_{suffix}", + "permission_name": f"updateperm_tempadmin_{suffix}", + "description": "desc" + }) + perm_id = perm.json()["id"] + # Grant admin role to temp user + resp = await authentication_web.query_roles({"role_key": "admin"}) + admin_role_id = resp.json()["items"][0]["id"] + await authentication_web.assign_roles_to_user({ + "user_id": temp_authentication_web.user_id, + "role_ids": [admin_role_id] + }) + # Re-login as temp user + temp_authentication_web.login() + # Try to update as temp user + update_data = { + "permission_id": perm_id, + "permission_key": f"updateperm_tempadmin_{suffix}_new", + "permission_name": f"updateperm_tempadmin_{suffix}_new", + "description": "desc new" + } + resp = await temp_authentication_web.update_permission(update_data) + assert resp.status_code == 200 + if __name__ == '__main__': pytest.main([__file__]) \ No newline at end of file From 0f7d63f4a2d813de12b0fb46efafb1bb0e2f43b6 Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 22 Jul 2025 17:14:30 +0800 Subject: [PATCH 14/19] feat(role_management): add test report for role management --- apps/authentication/requirements.txt | 4 +- .../tests/api_tests/permission/README.md | 86 ++++++++++++++++ .../tests/api_tests/role/README.md | 99 +++++++++++++++++++ .../tests/api_tests/siginin/README.md | 37 +++++++ .../tests/api_tests/user/README.md | 45 +++++++++ apps/authentication/tests/base/config.py | 6 +- 6 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 apps/authentication/tests/api_tests/permission/README.md create mode 100644 apps/authentication/tests/api_tests/role/README.md create mode 100644 apps/authentication/tests/api_tests/siginin/README.md create mode 100644 apps/authentication/tests/api_tests/user/README.md diff --git a/apps/authentication/requirements.txt b/apps/authentication/requirements.txt index 83faa17..26ce4b6 100644 --- a/apps/authentication/requirements.txt +++ b/apps/authentication/requirements.txt @@ -13,4 +13,6 @@ httpx pydantic-settings python-jose passlib[bcrypt] -prometheus-fastapi-instrumentator==7.0.2 \ No newline at end of file +prometheus-fastapi-instrumentator==7.0.2 +pytest==8.4.1 +pytest-asyncio==0.21.2 \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/permission/README.md b/apps/authentication/tests/api_tests/permission/README.md new file mode 100644 index 0000000..9d0ad81 --- /dev/null +++ b/apps/authentication/tests/api_tests/permission/README.md @@ -0,0 +1,86 @@ +# Permission API Test Report + +## How to Run the Tests + + **Run all permission API tests with coverage:** + ```bash + pytest --cov=authentication --cov-report=term-missing tests/api_tests/permission/ + ``` + +--- + +## Test Results Summary + +- **Total tests collected:** 26 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + - Coverage warning: `Module authentication was never imported. (module-not-imported)` + +--- + +## Test Case Explanations + +### test_create_permission.py + +- **test_create_permission_success** + Admin user can create a permission with valid data. +- **test_create_permission_fail_duplicate_key/name** + Creating a permission with duplicate key or name fails. +- **test_create_permission_fail_empty_key/name** + Creating a permission with empty key or name fails. +- **test_create_permission_success_empty_description** + Description is optional. +- **test_create_permission_fail_by_non_admin** + Non-admin user cannot create permissions. +- **test_create_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can create permissions. + +### test_delete_permission.py + +- **test_delete_permission_success** + Admin user can delete a permission. +- **test_delete_permission_fail_not_found** + Deleting a non-existent permission fails. +- **test_delete_default_permission_fail** + Default permissions cannot be deleted. +- **test_delete_permission_fail_by_non_admin** + Non-admin user cannot delete permissions. +- **test_delete_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can delete permissions. + +### test_update_permission.py + +- **test_update_permission_success** + Admin user can update a permission. +- **test_update_permission_fail_not_found** + Updating a non-existent permission fails. +- **test_update_permission_fail_duplicate_key/name** + Updating to a duplicate key or name fails. +- **test_update_permission_fail_empty_key/name** + Updating with empty key or name fails. +- **test_update_default_permission_fail** + Default permissions cannot be updated. +- **test_update_permission_fail_by_non_admin** + Non-admin user cannot update permissions. +- **test_update_permission_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can update permissions. + +### test_query_permission.py + +- **test_query_all_permissions** + Query all permissions, expect a list. +- **test_query_permissions_by_key/name** + Query permissions by key or name (fuzzy search). +- **test_query_permissions_pagination** + Query permissions with pagination. + +--- + +## Summary + +- These tests ensure that only admin users can manage permissions, and that permission can be delegated by granting the admin role to other users. +- Each test case is designed to verify both positive and negative scenarios, including permission escalation and proper error handling. +- **Coverage reporting is not working** due to import or execution issues—fix this for a complete report. + +--- diff --git a/apps/authentication/tests/api_tests/role/README.md b/apps/authentication/tests/api_tests/role/README.md new file mode 100644 index 0000000..9e93dd8 --- /dev/null +++ b/apps/authentication/tests/api_tests/role/README.md @@ -0,0 +1,99 @@ +# Role API Test Report + +## How to Run the Tests + +**Run all role API tests:** +```bash +pytest --tb=short tests/api_tests/role/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 33 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_assign_permissions.py +- **test_assign_permissions_success** + Assign multiple permissions to a role successfully. +- **test_assign_permissions_fail_role_not_found** + Assigning permissions to a non-existent role fails. +- **test_assign_permissions_fail_permission_not_found** + Assigning a non-existent permission to a role fails. +- **test_assign_permissions_fail_empty_permission_ids** + Assigning with an empty permission list fails. +- **test_assign_permissions_fail_empty_role_id** + Assigning with an empty role ID fails. +- **test_assign_permissions_remove_duplicates** + Assigning duplicate permission IDs results in de-duplication. +- **test_assign_permissions_to_default_role** + Assigning permissions to a default role (should succeed if not restricted). + +### test_create_role.py +- **test_create_role_success** + Admin user can create a role with valid and unique data. +- **test_create_role_fail_duplicate_role_key/name** + Creating a role with duplicate key or name fails. +- **test_create_role_fail_empty_role_key/name** + Creating a role with empty key or name fails. +- **test_create_role_success_empty_description** + Description is optional. +- **test_create_role_fail_by_non_admin** + Non-admin user cannot create roles. +- **test_create_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can create roles. + +### test_delete_role.py +- **test_delete_role_success** + Admin user can delete a role. +- **test_delete_role_fail_not_found** + Deleting a non-existent role fails. +- **test_delete_default_role_fail** + Default roles cannot be deleted. +- **test_delete_role_fail_by_non_admin** + Non-admin user cannot delete roles. +- **test_delete_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can delete roles. + +### test_query_role.py +- **test_query_all_roles** + Query all roles, expect a list. +- **test_query_roles_by_key/name** + Query roles by key or name (fuzzy search). +- **test_query_roles_pagination** + Query roles with pagination. + +### test_update_role.py +- **test_update_role_success** + Admin user can update a role with valid and unique data. +- **test_update_role_fail_not_found** + Updating a non-existent role fails. +- **test_update_role_fail_duplicate_key/name** + Updating to a duplicate key or name fails. +- **test_update_role_fail_empty_key/name** + Updating with empty key or name fails. +- **test_update_default_role_fail** + Default roles cannot be updated. +- **test_update_role_fail_by_non_admin** + Non-admin user cannot update roles. +- **test_update_role_success_after_grant_admin** + After admin grants admin role to a temp user and the user re-logs in, the user can update roles. + +--- + +## Summary + +- These tests ensure that only admin users can manage roles, and that permission can be delegated by granting the admin role to other users. +- Each test case is designed to verify both positive and negative scenarios, including permission escalation and proper error handling. +- **Coverage reporting is not included in this report.** + +--- + +If you need a more detailed, markdown-formatted report with actual coverage numbers, please enable coverage and re-run the tests. \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/siginin/README.md b/apps/authentication/tests/api_tests/siginin/README.md new file mode 100644 index 0000000..1dcc038 --- /dev/null +++ b/apps/authentication/tests/api_tests/siginin/README.md @@ -0,0 +1,37 @@ +# Signin API Test Report + +## How to Run the Tests + +**Run all signin API tests:** +```bash +pytest --tb=short tests/api_tests/siginin/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 1 +- **All tests passed.** +- **Warnings:** + - Deprecation warning from Pydantic (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_signin_with_email_and_password.py +- **test_sign_in_with_email_and_password** + This test verifies the email and password sign-in flow: + - Calls the login API with valid credentials. + - Asserts that the response contains a valid access token, refresh token, expiration, identity, role names, and user permissions. + - Decodes the JWT access token and checks that the payload contains the expected subject fields (id, role_names, user_permissions). + +--- + +## Summary + +- This test ensures that the email/password sign-in API returns all required authentication and user information fields, and that the JWT token is correctly structured. +- If you need to add more signin scenarios, add new test cases to this directory and re-run the tests. + +--- \ No newline at end of file diff --git a/apps/authentication/tests/api_tests/user/README.md b/apps/authentication/tests/api_tests/user/README.md new file mode 100644 index 0000000..55f3c93 --- /dev/null +++ b/apps/authentication/tests/api_tests/user/README.md @@ -0,0 +1,45 @@ +# User API Test Report + +## How to Run the Tests + +**Run all user API tests:** +```bash +pytest --tb=short tests/api_tests/user/ +``` + +--- + +## Test Results Summary + +- **Total tests collected:** 6 +- **All tests passed.** +- **Warnings:** + - Deprecation warnings from Pydantic/Beanie (upgrade recommended for future compatibility). + +--- + +## Test Case Explanations + +### test_assign_roles.py +- **test_assign_roles_success_by_admin** + Admin user can assign a role to a user successfully. +- **test_assign_roles_fail_by_non_admin** + Non-admin user cannot assign roles to other users (permission denied). +- **test_assign_roles_fail_role_not_found** + Assigning a non-existent role to a user fails. +- **test_assign_roles_fail_empty_role_ids** + Assigning with an empty role list fails. +- **test_assign_roles_fail_empty_user_id** + Assigning roles with an empty user ID fails. +- **test_assign_roles_remove_duplicates** + Assigning duplicate role IDs results in de-duplication; the user ends up with a single instance of the role. + +--- + +## Summary + +- These tests ensure that only admin users can assign roles to users, and that the system properly handles invalid input and duplicate assignments. +- Each test case is designed to verify both positive and negative scenarios, including permission checks and input validation. +- If you need to add more user management scenarios, add new test cases to this directory and re-run the tests. + +--- \ No newline at end of file diff --git a/apps/authentication/tests/base/config.py b/apps/authentication/tests/base/config.py index 26b5455..023d94b 100644 --- a/apps/authentication/tests/base/config.py +++ b/apps/authentication/tests/base/config.py @@ -1,3 +1,5 @@ -USER_EMAIL = "icecheng@mathmast.com" -USER_PASSWORD = "@Cwb1535145760" +# user with admin role +USER_EMAIL = "XXXXX" +USER_PASSWORD = "XXXXX" +# authentication base url BASE_URL = "http://localhost:8103" \ No newline at end of file From 30e6ca72a76840f9f14fbf7682f1de277c5d60af Mon Sep 17 00:00:00 2001 From: icecheng Date: Fri, 25 Jul 2025 10:00:08 +0800 Subject: [PATCH 15/19] feat(refactor): remove duplicate get_current_user --- .../common/token/token_manager.py | 68 ++++++++++++---- apps/authentication/common/utils/jwt_token.py | 77 ------------------- apps/authentication/tests/base/config.py | 4 +- apps/authentication/webapi/main.py | 1 - .../webapi/routes/auth/send_email_code.py | 8 +- .../webapi/routes/auth/send_mobile_code.py | 8 +- .../routes/permission/create_permission.py | 3 +- .../routes/permission/delete_permission.py | 5 +- .../routes/permission/update_permission.py | 4 +- .../webapi/routes/role/assign_permissions.py | 3 +- .../webapi/routes/role/create_role.py | 3 +- .../webapi/routes/role/delete_role.py | 5 +- .../webapi/routes/role/update_role.py | 3 +- .../webapi/routes/signin/sign_out.py | 6 +- .../webapi/routes/user/assign_roles.py | 3 +- 15 files changed, 81 insertions(+), 120 deletions(-) delete mode 100644 apps/authentication/common/utils/jwt_token.py diff --git a/apps/authentication/common/token/token_manager.py b/apps/authentication/common/token/token_manager.py index dcb4558..1758fb7 100644 --- a/apps/authentication/common/token/token_manager.py +++ b/apps/authentication/common/token/token_manager.py @@ -1,11 +1,34 @@ from datetime import datetime, timedelta, timezone import uuid -from typing import Dict +from typing import Dict, List from jose import jwt, JWTError from common.config.app_settings import app_settings from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer -from starlette.status import HTTP_401_UNAUTHORIZED +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS + + +class CurrentUser: + def __init__(self, user_id: str, user_role_names: List[str], user_permission_keys: List[str]): + self.user_id = user_id + self.user_role_names = user_role_names + self.user_permission_keys = user_permission_keys + + def has_all_permissions(self, permissions: List[str]) -> bool: + """Check if the user has all the specified permissions""" + if not permissions: + return True + return all(p in self.user_permission_keys for p in permissions) + + def has_any_permissions(self, permissions: List[str]) -> bool: + """Check if the user has at least one of the specified permissions""" + if not permissions: + return True + return any(p in self.user_permission_keys for p in permissions) + + +security = HTTPBearer() class TokenManager: @@ -73,16 +96,35 @@ class TokenManager: else: raise ValueError("Invalid refresh token") - async def get_current_user( - self, token: str = Depends(OAuth2PasswordBearer(tokenUrl="token")) - ) -> Dict: + async def get_current_user(self, credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser: """ - Extract and validate user information from the JWT token. + Returns the current user object for the given credentials. """ try: - payload = self.decode_token(token) # Decode JWT token - return payload - except ValueError: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" - ) + payload = self.decode_token(credentials.credentials) + user = payload.get("subject") + if not user or "id" not in user: + raise HTTPException(status_code=401, detail="Invalid authentication token") + return CurrentUser(user.get("id"), user.get(USER_ROLE_NAMES), user.get(USER_PERMISSIONS)) + except JWTError: + raise HTTPException(status_code=401, detail="Invalid authentication token") + + def has_all_permissions(self, permissions: List[str]): + """Check if the user has all the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(self.get_current_user)): + if not current_user.has_all_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency + + def has_any_permissions(self, permissions: List[str]): + """Check if the user has at least one of the specified permissions""" + + def inner_dependency(current_user: CurrentUser = Depends(self.get_current_user)): + if not current_user.has_any_permissions(permissions): + raise HTTPException(status_code=403, detail="Not allowed") + return True + + return inner_dependency diff --git a/apps/authentication/common/utils/jwt_token.py b/apps/authentication/common/utils/jwt_token.py deleted file mode 100644 index 9247816..0000000 --- a/apps/authentication/common/utils/jwt_token.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import List - -from fastapi import Depends, HTTPException -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from jose import jwt, JWTError - -from common.config.app_settings import app_settings -from common.constants.jwt_constants import USER_ROLE_NAMES, USER_PERMISSIONS - -security = HTTPBearer() - - -class CurrentUser: - def __init__(self, user_id: str, user_role_names: List[str], user_permission_keys: List[str]): - self.user_id = user_id - self.user_role_names = user_role_names - self.user_permission_keys = user_permission_keys - - def has_all_permissions(self, permissions: List[str]) -> bool: - """Check if the user has all the specified permissions""" - if not permissions: - return True - return all(p in self.user_permission_keys for p in permissions) - - def has_any_permissions(self, permissions: List[str]) -> bool: - """Check if the user has at least one of the specified permissions""" - if not permissions: - return True - return any(p in self.user_permission_keys for p in permissions) - - -def decode_jwt_token(token: str): - payload = jwt.decode( - token, - app_settings.JWT_SECRET_KEY, - algorithms=[app_settings.JWT_ALGORITHM], - ) - return payload - - -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), -) -> CurrentUser: - try: - payload = decode_jwt_token(credentials.credentials) - user = payload.get("subject") - if not user or "id" not in user: - raise HTTPException(status_code=401, detail="Invalid authentication token") - return CurrentUser(user.get("id"), user.get(USER_ROLE_NAMES), user.get(USER_PERMISSIONS)) - except JWTError: - raise HTTPException(status_code=401, detail="Invalid authentication token") - - -def has_all_permissions( - permissions: List[str], -): - """Check if the user has all the specified permissions""" - - def inner_dependency(current_user: CurrentUser = Depends(get_current_user)): - if not current_user.has_all_permissions(permissions): - raise HTTPException(status_code=403, detail="Not allowed") - return True - - return inner_dependency - - -def has_any_permissions( - permissions: List[str] -): - """Check if the user has at least one of the specified permissions""" - - def inner_dependency(current_user: CurrentUser = Depends(get_current_user)): - if not current_user.has_any_permissions(permissions): - raise HTTPException(status_code=403, detail="Not allowed") - return True - - return inner_dependency diff --git a/apps/authentication/tests/base/config.py b/apps/authentication/tests/base/config.py index 023d94b..f77bcab 100644 --- a/apps/authentication/tests/base/config.py +++ b/apps/authentication/tests/base/config.py @@ -1,5 +1,5 @@ # user with admin role -USER_EMAIL = "XXXXX" -USER_PASSWORD = "XXXXX" +USER_EMAIL = "XXXX" +USER_PASSWORD = "XXXX" # authentication base url BASE_URL = "http://localhost:8103" \ No newline at end of file diff --git a/apps/authentication/webapi/main.py b/apps/authentication/webapi/main.py index c35ffa3..d325986 100755 --- a/apps/authentication/webapi/main.py +++ b/apps/authentication/webapi/main.py @@ -1,7 +1,6 @@ from webapi.bootstrap.application import create_app from webapi.config.site_settings import site_settings from fastapi.responses import RedirectResponse -from fastapi.middleware.cors import CORSMiddleware import uvicorn from typing import Any diff --git a/apps/authentication/webapi/routes/auth/send_email_code.py b/apps/authentication/webapi/routes/auth/send_email_code.py index 050b791..8e70a64 100644 --- a/apps/authentication/webapi/routes/auth/send_email_code.py +++ b/apps/authentication/webapi/routes/auth/send_email_code.py @@ -1,6 +1,6 @@ from backend.application.signin_hub import SignInHub from pydantic import BaseModel -from common.token.token_manager import TokenManager +from common.token.token_manager import TokenManager, CurrentUser from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from fastapi import APIRouter, Depends, HTTPException @@ -28,14 +28,14 @@ class RequestIn(BaseModel): ) async def send_email_code( item: RequestIn, - current_user: dict = Depends(token_manager.get_current_user), + current_user: CurrentUser = Depends(token_manager.get_current_user), ): - user_id = current_user.get("id") + user_id = current_user.user_id if not user_id: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) - result = await SignInHub(user_id).send_email_code(item.sender_id, item.email) + result = await SignInHub().send_email_code(item.sender_id, item.email) return JSONResponse(content=jsonable_encoder(result)) diff --git a/apps/authentication/webapi/routes/auth/send_mobile_code.py b/apps/authentication/webapi/routes/auth/send_mobile_code.py index 29bdd5b..4347888 100644 --- a/apps/authentication/webapi/routes/auth/send_mobile_code.py +++ b/apps/authentication/webapi/routes/auth/send_mobile_code.py @@ -1,6 +1,6 @@ from backend.application.signin_hub import SignInHub from pydantic import BaseModel -from common.token.token_manager import TokenManager +from common.token.token_manager import TokenManager, CurrentUser from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from fastapi import APIRouter, Depends, HTTPException @@ -27,14 +27,14 @@ class RequestIn(BaseModel): ) async def send_email_code( item: RequestIn, - current_user: dict = Depends(token_manager.get_current_user), + current_user: CurrentUser = Depends(token_manager.get_current_user), ): - user_id = current_user.get("id") + user_id = current_user.user_id if not user_id: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) - result = await SignInHub(user_id).send_mobile_code(item.email) + result = await SignInHub().send_mobile_code(item.email) return JSONResponse(content=jsonable_encoder(result)) diff --git a/apps/authentication/webapi/routes/permission/create_permission.py b/apps/authentication/webapi/routes/permission/create_permission.py index 9d8af34..cffb410 100644 --- a/apps/authentication/webapi/routes/permission/create_permission.py +++ b/apps/authentication/webapi/routes/permission/create_permission.py @@ -7,7 +7,6 @@ from typing import Optional from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -38,7 +37,7 @@ class PermissionResponse(BaseModel): ) async def create_permission( req: CreatePermissionRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) ) -> PermissionResponse: doc = await permission_service.create_permission(req.permission_key, req.permission_name, req.description) diff --git a/apps/authentication/webapi/routes/permission/delete_permission.py b/apps/authentication/webapi/routes/permission/delete_permission.py index efcd0d1..be8f344 100644 --- a/apps/authentication/webapi/routes/permission/delete_permission.py +++ b/apps/authentication/webapi/routes/permission/delete_permission.py @@ -3,8 +3,9 @@ from pydantic import BaseModel from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService -from common.utils.jwt_token import has_all_permissions +from common.token.token_manager import TokenManager +token_manager = TokenManager() router = APIRouter() permission_service = PermissionService() @@ -26,7 +27,7 @@ class DeletePermissionResponse(BaseModel): ) async def delete_permission( req: DeletePermissionRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) ) -> DeletePermissionResponse: await permission_service.delete_permission(req.permission_id) return DeletePermissionResponse(success=True) diff --git a/apps/authentication/webapi/routes/permission/update_permission.py b/apps/authentication/webapi/routes/permission/update_permission.py index fa811e5..176b231 100644 --- a/apps/authentication/webapi/routes/permission/update_permission.py +++ b/apps/authentication/webapi/routes/permission/update_permission.py @@ -7,7 +7,7 @@ from typing import Optional from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.permission_service import PermissionService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions + router = APIRouter() token_manager = TokenManager() @@ -39,7 +39,7 @@ class PermissionResponse(BaseModel): ) async def update_permission( req: UpdatePermissionRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_PERMISSIONS.value.permission_key])) ) -> PermissionResponse: doc = await permission_service.update_permission(req.permission_id, req.permission_key, req.permission_name, req.description) diff --git a/apps/authentication/webapi/routes/role/assign_permissions.py b/apps/authentication/webapi/routes/role/assign_permissions.py index c18aa11..3095a4a 100644 --- a/apps/authentication/webapi/routes/role/assign_permissions.py +++ b/apps/authentication/webapi/routes/role/assign_permissions.py @@ -7,7 +7,6 @@ from typing import List from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -36,7 +35,7 @@ class RoleResponse(BaseModel): ) async def assign_permissions_to_role( req: AssignPermissionsRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.assign_permissions_to_role(req.role_id, req.permission_ids) return RoleResponse(**doc.dict()) \ No newline at end of file diff --git a/apps/authentication/webapi/routes/role/create_role.py b/apps/authentication/webapi/routes/role/create_role.py index 2677591..2936f31 100644 --- a/apps/authentication/webapi/routes/role/create_role.py +++ b/apps/authentication/webapi/routes/role/create_role.py @@ -7,7 +7,6 @@ from typing import Optional, List from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -41,7 +40,7 @@ class RoleResponse(BaseModel): ) async def create_role( req: CreateRoleRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.create_role(req.role_key, req.role_name, req.role_description, req.role_level) return RoleResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/role/delete_role.py b/apps/authentication/webapi/routes/role/delete_role.py index 5c34823..95f6d5a 100644 --- a/apps/authentication/webapi/routes/role/delete_role.py +++ b/apps/authentication/webapi/routes/role/delete_role.py @@ -3,8 +3,9 @@ from pydantic import BaseModel from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService -from common.utils.jwt_token import has_all_permissions +from common.token.token_manager import TokenManager +token_manager = TokenManager() router = APIRouter() role_service = RoleService() @@ -26,7 +27,7 @@ class DeleteRoleResponse(BaseModel): ) async def delete_role( req: DeleteRoleRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> DeleteRoleResponse: await role_service.delete_role(req.role_id) return DeleteRoleResponse(success=True) diff --git a/apps/authentication/webapi/routes/role/update_role.py b/apps/authentication/webapi/routes/role/update_role.py index cd6a445..71717a2 100644 --- a/apps/authentication/webapi/routes/role/update_role.py +++ b/apps/authentication/webapi/routes/role/update_role.py @@ -7,7 +7,6 @@ from typing import Optional, List from backend.models.permission.constants import DefaultPermissionEnum from backend.services.permission.role_service import RoleService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -42,7 +41,7 @@ class RoleResponse(BaseModel): ) async def update_role( req: UpdateRoleRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.CHANGE_ROLES.value.permission_key])) ) -> RoleResponse: doc = await role_service.update_role(req.role_id, req.role_key, req.role_name, req.role_description, req.role_level) return RoleResponse(**doc.dict()) diff --git a/apps/authentication/webapi/routes/signin/sign_out.py b/apps/authentication/webapi/routes/signin/sign_out.py index 18395b2..040821f 100644 --- a/apps/authentication/webapi/routes/signin/sign_out.py +++ b/apps/authentication/webapi/routes/signin/sign_out.py @@ -1,7 +1,7 @@ from backend.application.signin_hub import SignInHub from pydantic import BaseModel from fastapi import APIRouter -from common.token.token_manager import TokenManager +from common.token.token_manager import TokenManager, CurrentUser from fastapi import APIRouter, Depends from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse @@ -28,9 +28,9 @@ class RequestIn(BaseModel): ) async def sign_out( item: RequestIn, - current_user: dict = Depends(token_manager.get_current_user), + current_user: CurrentUser = Depends(token_manager.get_current_user), ): - user_id = current_user.get("id") + user_id = current_user.user_id if not user_id: raise HTTPException( diff --git a/apps/authentication/webapi/routes/user/assign_roles.py b/apps/authentication/webapi/routes/user/assign_roles.py index 4ad806c..e14c89b 100644 --- a/apps/authentication/webapi/routes/user/assign_roles.py +++ b/apps/authentication/webapi/routes/user/assign_roles.py @@ -6,7 +6,6 @@ from typing import List, Optional from backend.models.permission.constants import DefaultPermissionEnum from backend.services.user.user_management_service import UserManagementService from common.token.token_manager import TokenManager -from common.utils.jwt_token import has_all_permissions router = APIRouter() token_manager = TokenManager() @@ -32,7 +31,7 @@ class UserRoleResponse(BaseModel): ) async def assign_roles_to_user( req: AssignRolesRequest, - _: bool = Depends(has_all_permissions([DefaultPermissionEnum.ASSIGN_ROLES.value.permission_key])), + _: bool = Depends(token_manager.has_all_permissions([DefaultPermissionEnum.ASSIGN_ROLES.value.permission_key])), ) -> UserRoleResponse: doc = await user_management_service.assign_roles_to_user(req.user_id, req.role_ids) return UserRoleResponse(**doc.dict()) From 69d1007ddf21e71f2e40e05783c6a57e15592f04 Mon Sep 17 00:00:00 2001 From: icecheng Date: Fri, 25 Jul 2025 16:20:50 +0800 Subject: [PATCH 16/19] feat(test): add unittest for role management, add coverage report for role management --- apps/authentication/tests/__init__.py | 0 .../test_signin_with_email_and_password.py | 1 - .../tests/role_manage_coverage_report.md | 28 +++ .../tests/unit_tests/__init__.py | 0 .../tests/unit_tests/backend/__init__.py | 0 .../unit_tests/backend/infra/__init__.py | 0 .../backend/infra/permission/__init__.py | 0 .../permission/permission_handler/__init__.py | 0 .../test_permission_handler.py | 137 ++++++++++++++ .../infra/permission/role_handler/__init__.py | 0 .../role_handler/test_role_handler.py | 169 +++++++++++++++++ .../permission/user_role_handler/__init__.py | 0 .../test_user_role_handler.py | 43 +++++ .../unit_tests/backend/permission/__init__.py | 0 .../permission/permission_service/__init__.py | 0 .../test_permission_service.py | 152 ++++++++++++++++ .../permission/role_service/__init__.py | 0 .../role_service/test_role_service.py | 172 ++++++++++++++++++ .../permission/user_role_handler/__init__.py | 0 .../tests/unit_tests/backend/user/__init__.py | 0 .../user/user_management_service/__init__.py | 0 .../test_user_management_service.py | 172 ++++++++++++++++++ apps/authentication/webapi/__init__.py | 0 23 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 apps/authentication/tests/__init__.py create mode 100644 apps/authentication/tests/role_manage_coverage_report.md create mode 100644 apps/authentication/tests/unit_tests/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/permission_service/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/permission_service/test_permission_service.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/role_service/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/role_service/test_role_service.py create mode 100644 apps/authentication/tests/unit_tests/backend/permission/user_role_handler/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/user/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/user/user_management_service/__init__.py create mode 100644 apps/authentication/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py create mode 100644 apps/authentication/webapi/__init__.py diff --git a/apps/authentication/tests/__init__.py b/apps/authentication/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py index 34a5322..90e0d27 100644 --- a/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py +++ b/apps/authentication/tests/api_tests/siginin/test_signin_with_email_and_password.py @@ -1,7 +1,6 @@ import jwt import pytest -from common.config.app_settings import app_settings from tests.api_tests.siginin import config from tests.base.authentication_web import AuthenticationWeb diff --git a/apps/authentication/tests/role_manage_coverage_report.md b/apps/authentication/tests/role_manage_coverage_report.md new file mode 100644 index 0000000..fd3b0d4 --- /dev/null +++ b/apps/authentication/tests/role_manage_coverage_report.md @@ -0,0 +1,28 @@ +# Test Coverage Report (backend modules only) + +--- + +## Coverage Table + +| File Name | Statements | Missed | Coverage | +|------------------------------------------------------------------|------------|--------|----------| +| backend/infra/permission/permission_handler.py | 55 | 0 | 100% | +| backend/infra/permission/role_handler.py | 71 | 0 | 100% | +| backend/infra/permission/user_role_handler.py | 39 | 7 | 82% | +| backend/services/permission/permission_service.py | 20 | 0 | 100% | +| backend/services/permission/role_service.py | 24 | 0 | 100% | +| backend/services/user/user_management_service.py | 39 | 0 | 100% | + +--- + +## Summary + +This test report only includes the test coverage of functions related to role management. + +See the integration tests: +- tests/api_tests/permission/README.md +- tests/api_tests/role/README.md +- tests/api_tests/user/README.md +## TODO + +Add tests for the previous functions. diff --git a/apps/authentication/tests/unit_tests/__init__.py b/apps/authentication/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/__init__.py b/apps/authentication/tests/unit_tests/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/__init__.py b/apps/authentication/tests/unit_tests/backend/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/__init__.py b/apps/authentication/tests/unit_tests/backend/infra/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/__init__.py b/apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py b/apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py new file mode 100644 index 0000000..ad3451a --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/infra/permission/permission_handler/test_permission_handler.py @@ -0,0 +1,137 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.infra.permission.permission_handler import PermissionHandler +from backend.models.permission.models import PermissionDoc, RoleDoc +from beanie import PydanticObjectId + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.permission_handler.PermissionDoc') as MockPermissionDoc, \ + patch('backend.infra.permission.permission_handler.RoleDoc') as MockRoleDoc: + yield MockPermissionDoc, MockRoleDoc + +@pytest.mark.asyncio +class TestPermissionHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockPermissionDoc, self.MockRoleDoc = mock_db + self.handler = PermissionHandler() + + async def test_create_permission_success(self): + # Test creating a permission successfully + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[None, None]) + mock_doc = MagicMock(spec=PermissionDoc) + self.MockPermissionDoc.return_value = mock_doc + mock_doc.insert = AsyncMock() + result = await self.handler.create_permission('key', 'name', 'desc') + assert result == mock_doc + mock_doc.insert.assert_awaited_once() + + async def test_create_permission_missing_key_or_name(self): + # Test missing permission_key or permission_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.create_permission('', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', '', 'desc') + + async def test_create_permission_duplicate(self): + # Test duplicate permission_key or permission_name raises validation error + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[MagicMock(), None]) + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', 'name', 'desc') + self.MockPermissionDoc.find_one = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.create_permission('key', 'name', 'desc') + + async def test_update_permission_success(self): + # Test updating a permission successfully + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.find_one = AsyncMock(return_value=None) + mock_doc.save = AsyncMock() + result = await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc') + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_update_permission_missing_args(self): + # Test missing permission_id, permission_key or permission_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.update_permission(None, 'key', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), '', 'name', 'desc') + with pytest.raises(RequestValidationError): + await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', '', 'desc') + + async def test_update_permission_not_found(self): + # Test updating a non-existent permission raises validation error + self.MockPermissionDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc') + + async def test_update_permission_is_default(self): + # Test updating a default permission raises validation error + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = True + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc') + + async def test_update_permission_conflict(self): + # Test updating a permission with duplicate key or name raises validation error + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.update_permission(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc') + + async def test_query_permissions_success(self): + # Test querying permissions returns docs and total + mock_cursor = MagicMock() + mock_cursor.count = AsyncMock(return_value=2) + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = mock_cursor + mock_cursor.to_list = AsyncMock(return_value=['doc1', 'doc2']) + self.MockPermissionDoc.find.return_value = mock_cursor + docs, total = await self.handler.query_permissions('key', 'name', 0, 10) + assert docs == ['doc1', 'doc2'] + assert total == 2 + + async def test_delete_permission_success(self): + # Test deleting a permission successfully + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = False + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + mock_doc.delete = AsyncMock() + await self.handler.delete_permission(PydanticObjectId('507f1f77bcf86cd799439011')) + mock_doc.delete.assert_awaited_once() + + async def test_delete_permission_missing_id(self): + # Test missing permission_id raises validation error + with pytest.raises(RequestValidationError): + await self.handler.delete_permission(None) + + async def test_delete_permission_referenced_by_role(self): + # Test deleting a permission referenced by a role raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission(PydanticObjectId('507f1f77bcf86cd799439011')) + + async def test_delete_permission_not_found(self): + # Test deleting a non-existent permission raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + self.MockPermissionDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission(PydanticObjectId('507f1f77bcf86cd799439011')) + + async def test_delete_permission_is_default(self): + # Test deleting a default permission raises validation error + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.is_default = True + self.MockPermissionDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.delete_permission(PydanticObjectId('507f1f77bcf86cd799439011')) \ No newline at end of file diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/__init__.py b/apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py b/apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py new file mode 100644 index 0000000..e1c553d --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/infra/permission/role_handler/test_role_handler.py @@ -0,0 +1,169 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.infra.permission.role_handler import RoleHandler +from backend.models.permission.models import RoleDoc, PermissionDoc, UserRoleDoc +from beanie import PydanticObjectId + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.role_handler.RoleDoc') as MockRoleDoc, \ + patch('backend.infra.permission.role_handler.PermissionDoc') as MockPermissionDoc, \ + patch('backend.infra.permission.role_handler.UserRoleDoc') as MockUserRoleDoc: + yield MockRoleDoc, MockPermissionDoc, MockUserRoleDoc + +@pytest.mark.asyncio +class TestRoleHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockRoleDoc, self.MockPermissionDoc, self.MockUserRoleDoc = mock_db + self.handler = RoleHandler() + + async def test_create_role_success(self): + # Test creating a role successfully + self.MockRoleDoc.find_one = AsyncMock(side_effect=[None, None]) + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.return_value = mock_doc + mock_doc.insert = AsyncMock() + result = await self.handler.create_role('key', 'name', 'desc', 1) + assert result == mock_doc + mock_doc.insert.assert_awaited_once() + + async def test_create_role_missing_key_or_name(self): + # Test missing role_key or role_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.create_role('', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', '', 'desc', 1) + + async def test_create_role_duplicate(self): + # Test duplicate role_key or role_name raises validation error + self.MockRoleDoc.find_one = AsyncMock(side_effect=[MagicMock(), None]) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', 'name', 'desc', 1) + self.MockRoleDoc.find_one = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.create_role('key', 'name', 'desc', 1) + + async def test_update_role_success(self): + # Test updating a role successfully + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc.save = AsyncMock() + result = await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc', 1) + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_update_role_missing_args(self): + # Test missing role_id, role_key or role_name raises validation error + with pytest.raises(RequestValidationError): + await self.handler.update_role(None, 'key', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), '', 'name', 'desc', 1) + with pytest.raises(RequestValidationError): + await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', '', 'desc', 1) + + async def test_update_role_not_found(self): + # Test updating a non-existent role raises validation error + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc', 1) + + async def test_update_role_is_default(self): + # Test updating a default role raises validation error + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = True + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc', 1) + + async def test_update_role_conflict(self): + # Test updating a role with duplicate key or name raises validation error + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.update_role(PydanticObjectId('507f1f77bcf86cd799439011'), 'key', 'name', 'desc', 1) + + async def test_query_roles_success(self): + # Test querying roles returns docs and total + mock_cursor = MagicMock() + mock_cursor.count = AsyncMock(return_value=2) + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = mock_cursor + mock_cursor.to_list = AsyncMock(return_value=['doc1', 'doc2']) + self.MockRoleDoc.find.return_value = mock_cursor + docs, total = await self.handler.query_roles('key', 'name', 0, 10) + assert docs == ['doc1', 'doc2'] + assert total == 2 + + async def test_assign_permissions_to_role_success(self): + # Test assigning permissions to a role successfully + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.get = AsyncMock(return_value=MagicMock()) + mock_doc.save = AsyncMock() + result = await self.handler.assign_permissions_to_role(PydanticObjectId('507f1f77bcf86cd799439011'), ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439011']) + assert result == mock_doc + mock_doc.save.assert_awaited_once() + + async def test_assign_permissions_to_role_missing_args(self): + # Test missing role_id or permission_ids raises validation error + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role(None, ['pid1']) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role(PydanticObjectId('507f1f77bcf86cd799439011'), None) + + async def test_assign_permissions_to_role_role_not_found(self): + # Test assigning permissions to a non-existent role raises validation error + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role(PydanticObjectId('507f1f77bcf86cd799439011'), ['507f1f77bcf86cd799439011']) + + async def test_assign_permissions_to_role_permission_not_found(self): + # Test assigning a non-existent permission raises validation error + mock_doc = MagicMock(spec=RoleDoc) + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + self.MockPermissionDoc.get = AsyncMock(side_effect=[None, MagicMock()]) + with pytest.raises(RequestValidationError): + await self.handler.assign_permissions_to_role(PydanticObjectId('507f1f77bcf86cd799439011'), ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439011']) + + async def test_delete_role_success(self): + # Test deleting a role successfully + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = False + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + mock_doc.delete = AsyncMock() + await self.handler.delete_role(PydanticObjectId('507f1f77bcf86cd799439011')) + mock_doc.delete.assert_awaited_once() + + async def test_delete_role_missing_id(self): + # Test missing role_id raises validation error + with pytest.raises(RequestValidationError): + await self.handler.delete_role(None) + + async def test_delete_role_referenced_by_user(self): + # Test deleting a role referenced by a user raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=MagicMock()) + with pytest.raises(RequestValidationError): + await self.handler.delete_role(PydanticObjectId('507f1f77bcf86cd799439011')) + + async def test_delete_role_not_found(self): + # Test deleting a non-existent role raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + self.MockRoleDoc.get = AsyncMock(return_value=None) + with pytest.raises(RequestValidationError): + await self.handler.delete_role(PydanticObjectId('507f1f77bcf86cd799439011')) + + async def test_delete_role_is_default(self): + # Test deleting a default role raises validation error + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.is_default = True + self.MockRoleDoc.get = AsyncMock(return_value=mock_doc) + with pytest.raises(RequestValidationError): + await self.handler.delete_role(PydanticObjectId('507f1f77bcf86cd799439011')) \ No newline at end of file diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py b/apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py b/apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py new file mode 100644 index 0000000..2dcc04e --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/infra/permission/user_role_handler/test_user_role_handler.py @@ -0,0 +1,43 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from backend.infra.permission.user_role_handler import UserRoleHandler +from backend.models.permission.models import RoleDoc, UserRoleDoc, PermissionDoc + +@pytest.fixture(autouse=True) +def mock_db(): + with patch('backend.infra.permission.user_role_handler.RoleDoc') as MockRoleDoc, \ + patch('backend.infra.permission.user_role_handler.UserRoleDoc') as MockUserRoleDoc, \ + patch('backend.infra.permission.user_role_handler.PermissionDoc') as MockPermissionDoc: + yield MockRoleDoc, MockUserRoleDoc, MockPermissionDoc + +@pytest.mark.asyncio +class TestUserRoleHandler: + @pytest.fixture(autouse=True) + def setup(self, mock_db): + self.MockRoleDoc, self.MockUserRoleDoc, self.MockPermissionDoc = mock_db + self.handler = UserRoleHandler() + + async def test_assign_roles_to_user_success(self): + # Test assigning roles to a user when no UserRoleDoc exists (create new) + self.MockRoleDoc.get = AsyncMock(return_value=MagicMock()) + self.MockUserRoleDoc.find_one = AsyncMock(return_value=None) + mock_user_role = MagicMock(spec=UserRoleDoc) + self.MockUserRoleDoc.return_value = mock_user_role + mock_user_role.insert = AsyncMock() + result = await self.handler.assign_roles_to_user('507f1f77bcf86cd799439011', ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']) + assert result == mock_user_role + mock_user_role.insert.assert_awaited_once() + + async def test_get_role_and_permission_by_user_id_with_roles_and_permissions(self): + # Test getting roles and permissions when user has roles and permissions + self.MockUserRoleDoc.find_one = AsyncMock(return_value=MagicMock(role_ids=['507f1f77bcf86cd799439010', '507f1f77bcf86cd799439017'])) + mock_role1 = MagicMock(role_name='role1', permission_ids=['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']) + mock_role2 = MagicMock(role_name='role2', permission_ids=['507f1f77bcf86cd799439014', '507f1f77bcf86cd799439013']) + self.MockRoleDoc.find.return_value.to_list = AsyncMock(return_value=[mock_role1, mock_role2]) + mock_perm1 = MagicMock(permission_key='perm1') + mock_perm2 = MagicMock(permission_key='perm2') + mock_perm3 = MagicMock(permission_key='perm3') + self.MockPermissionDoc.find.return_value.to_list = AsyncMock(return_value=[mock_perm1, mock_perm2, mock_perm3]) + result = await self.handler.get_role_and_permission_by_user_id('uid') + assert set(result[0]) == {'role1', 'role2'} + assert set(result[1]) == {'perm1', 'perm2', 'perm3'} \ No newline at end of file diff --git a/apps/authentication/tests/unit_tests/backend/permission/__init__.py b/apps/authentication/tests/unit_tests/backend/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/permission/permission_service/__init__.py b/apps/authentication/tests/unit_tests/backend/permission/permission_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/permission/permission_service/test_permission_service.py b/apps/authentication/tests/unit_tests/backend/permission/permission_service/test_permission_service.py new file mode 100644 index 0000000..2087462 --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/permission/permission_service/test_permission_service.py @@ -0,0 +1,152 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.services.permission.permission_service import PermissionService +from backend.models.permission.models import PermissionDoc + +import datetime + +@pytest.fixture +def mock_permission_handler(): + # Fixture to patch PermissionHandler for isolation and mocking + with patch('backend.services.permission.permission_service.PermissionHandler') as MockHandler: + yield MockHandler + +@pytest.mark.asyncio +class TestPermissionService: + @pytest.fixture(autouse=True) + def setup(self, mock_permission_handler): + # Automatically set up a PermissionService with a mocked handler for each test + self.mock_handler = mock_permission_handler.return_value + self.service = PermissionService() + self.service.permission_handler = self.mock_handler + + async def test_create_permission_success(self): + # Test creating a permission successfully returns the expected PermissionDoc + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', 'desc') + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', 'desc') + + async def test_create_permission_fail(self): + # Test that an exception is raised if the handler fails to create a permission + self.mock_handler.create_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.create_permission('key', 'name', 'desc') + + async def test_create_permission_with_none_description(self): + # Test creating a permission with None as description + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', None) + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', None) + + async def test_create_permission_with_empty_description(self): + # Test creating a permission with empty string as description + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.create_permission = AsyncMock(return_value=mock_doc) + result = await self.service.create_permission('key', 'name', '') + assert result == mock_doc + self.mock_handler.create_permission.assert_awaited_once_with('key', 'name', '') + + async def test_create_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during creation + self.mock_handler.create_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_permission('key', 'name', 'desc') + + async def test_update_permission_success(self): + # Test updating a permission successfully returns the expected PermissionDoc + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.update_permission = AsyncMock(return_value=mock_doc) + result = await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + assert result == mock_doc + self.mock_handler.update_permission.assert_awaited_once() + + async def test_update_permission_fail(self): + # Test that an exception is raised if the handler fails to update a permission + self.mock_handler.update_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_with_invalid_id(self): + # Test updating a permission with invalid id (empty string) + self.mock_handler.update_permission = AsyncMock(side_effect=RequestValidationError('invalid id')) + with pytest.raises(RequestValidationError): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during update + self.mock_handler.update_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.update_permission('507f1f77bcf86cd799439011', 'key', 'name', 'desc') + + async def test_update_permission_partial_args(self): + # Test updating a permission with only key provided + mock_doc = MagicMock(spec=PermissionDoc) + self.mock_handler.update_permission = AsyncMock(return_value=mock_doc) + result = await self.service.update_permission('507f1f77bcf86cd799439011', 'key', None, None) + assert result == mock_doc + self.mock_handler.update_permission.assert_awaited_once() + + async def test_query_permissions_success(self): + # Test querying permissions returns a paginated result with correct items and meta + mock_doc = MagicMock(spec=PermissionDoc) + mock_doc.dict.return_value = {'permission_key': 'key', 'permission_name': 'name'} + self.mock_handler.query_permissions = AsyncMock(return_value=([mock_doc], 1)) + result = await self.service.query_permissions('key', 'name', 1, 10) + assert result['items'] == [{'permission_key': 'key', 'permission_name': 'name'}] + assert result['total'] == 1 + assert result['page'] == 1 + assert result['page_size'] == 10 + self.mock_handler.query_permissions.assert_awaited_once_with('key', 'name', 0, 10) + + async def test_query_permissions_handler_exception(self): + # Test handler raises exception during query + self.mock_handler.query_permissions = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.query_permissions('key', 'name', 1, 10) + + async def test_query_permissions_empty_result(self): + # Test query returns empty list + self.mock_handler.query_permissions = AsyncMock(return_value=([], 0)) + result = await self.service.query_permissions('key', 'name', 1, 10) + assert result['items'] == [] + assert result['total'] == 0 + assert result['page'] == 1 + assert result['page_size'] == 10 + + async def test_query_permissions_with_none_and_special_chars(self): + # Test query with None and special characters + self.mock_handler.query_permissions = AsyncMock(return_value=([], 0)) + result = await self.service.query_permissions(None, None, 1, 10) + assert result['items'] == [] + result2 = await self.service.query_permissions('!@#$', '', 1, 10) + assert result2['items'] == [] + + @pytest.mark.parametrize('page,page_size', [(0, 10), (1, 0), (0, 0), (-1, 10), (1, -1)]) + async def test_query_permissions_invalid_page(self, page, page_size): + # Test that invalid page or page_size raises a validation error + with pytest.raises(RequestValidationError): + await self.service.query_permissions('key', 'name', page, page_size) + + async def test_delete_permission_success(self): + # Test deleting a permission successfully returns None + self.mock_handler.delete_permission = AsyncMock(return_value=None) + result = await self.service.delete_permission('507f1f77bcf86cd799439011') + assert result is None + self.mock_handler.delete_permission.assert_awaited_once() + + async def test_delete_permission_fail(self): + # Test that an exception is raised if the handler fails to delete a permission + self.mock_handler.delete_permission = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.delete_permission('507f1f77bcf86cd799439011') + + async def test_delete_permission_handler_unexpected_exception(self): + # Test handler raises unexpected exception during delete + self.mock_handler.delete_permission = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.delete_permission('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/apps/authentication/tests/unit_tests/backend/permission/role_service/__init__.py b/apps/authentication/tests/unit_tests/backend/permission/role_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/permission/role_service/test_role_service.py b/apps/authentication/tests/unit_tests/backend/permission/role_service/test_role_service.py new file mode 100644 index 0000000..77d858e --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/permission/role_service/test_role_service.py @@ -0,0 +1,172 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.exceptions import RequestValidationError +from backend.services.permission.role_service import RoleService +from backend.models.permission.models import RoleDoc + +@pytest.fixture +def mock_role_handler(): + # Fixture to patch RoleHandler for isolation and mocking + with patch('backend.services.permission.role_service.RoleHandler') as MockHandler: + yield MockHandler + +@pytest.mark.asyncio +class TestRoleService: + @pytest.fixture(autouse=True) + def setup(self, mock_role_handler): + # Automatically set up a RoleService with a mocked handler for each test + self.mock_handler = mock_role_handler.return_value + self.service = RoleService() + self.service.role_handler = self.mock_handler + + async def test_create_role_success(self): + # Test creating a role successfully returns the expected RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', 'desc', 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', 'desc', 1) + + async def test_create_role_fail(self): + # Test that an exception is raised if the handler fails to create a role + self.mock_handler.create_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.create_role('key', 'name', 'desc', 1) + + async def test_create_role_with_none_description(self): + # Test creating a role with None as description + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', None, 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', None, 1) + + async def test_create_role_with_empty_description(self): + # Test creating a role with empty string as description + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.create_role = AsyncMock(return_value=mock_doc) + result = await self.service.create_role('key', 'name', '', 1) + assert result == mock_doc + self.mock_handler.create_role.assert_awaited_once_with('key', 'name', '', 1) + + async def test_create_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during creation + self.mock_handler.create_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_role('key', 'name', 'desc', 1) + + async def test_update_role_success(self): + # Test updating a role successfully returns the expected RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.update_role = AsyncMock(return_value=mock_doc) + result = await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + assert result == mock_doc + self.mock_handler.update_role.assert_awaited_once() + + async def test_update_role_fail(self): + # Test that an exception is raised if the handler fails to update a role + self.mock_handler.update_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during update + self.mock_handler.update_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.update_role('507f1f77bcf86cd799439011', 'key', 'name', 'desc', 1) + + async def test_update_role_partial_args(self): + # Test updating a role with only key provided + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.update_role = AsyncMock(return_value=mock_doc) + result = await self.service.update_role('507f1f77bcf86cd799439011', 'key', None, None, 1) + assert result == mock_doc + self.mock_handler.update_role.assert_awaited_once() + + async def test_query_roles_success(self): + # Test querying roles returns a paginated result with correct items and meta + mock_doc = MagicMock(spec=RoleDoc) + mock_doc.dict.return_value = {'role_key': 'key', 'role_name': 'name'} + self.mock_handler.query_roles = AsyncMock(return_value=([mock_doc], 1)) + result = await self.service.query_roles('key', 'name', 1, 10) + assert result['items'] == [{'role_key': 'key', 'role_name': 'name'}] + assert result['total'] == 1 + assert result['page'] == 1 + assert result['page_size'] == 10 + self.mock_handler.query_roles.assert_awaited_once_with('key', 'name', 0, 10) + + async def test_query_roles_handler_exception(self): + # Test handler raises exception during query + self.mock_handler.query_roles = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.query_roles('key', 'name', 1, 10) + + async def test_query_roles_empty_result(self): + # Test query returns empty list + self.mock_handler.query_roles = AsyncMock(return_value=([], 0)) + result = await self.service.query_roles('key', 'name', 1, 10) + assert result['items'] == [] + assert result['total'] == 0 + assert result['page'] == 1 + assert result['page_size'] == 10 + + async def test_query_roles_with_none_and_special_chars(self): + # Test query with None and special characters + self.mock_handler.query_roles = AsyncMock(return_value=([], 0)) + result = await self.service.query_roles(None, None, 1, 10) + assert result['items'] == [] + result2 = await self.service.query_roles('!@#$', '', 1, 10) + assert result2['items'] == [] + + @pytest.mark.parametrize('page,page_size', [(0, 10), (1, 0), (0, 0), (-1, 10), (1, -1)]) + async def test_query_roles_invalid_page(self, page, page_size): + # Test that invalid page or page_size raises a validation error + with pytest.raises(RequestValidationError): + await self.service.query_roles('key', 'name', page, page_size) + + async def test_assign_permissions_to_role_success(self): + # Test assigning permissions to a role returns the updated RoleDoc + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.assign_permissions_to_role = AsyncMock(return_value=mock_doc) + result = await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1', 'pid2']) + assert result == mock_doc + self.mock_handler.assign_permissions_to_role.assert_awaited_once() + + async def test_assign_permissions_to_role_fail(self): + # Test that an exception is raised if the handler fails to assign permissions + self.mock_handler.assign_permissions_to_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1', 'pid2']) + + async def test_assign_permissions_to_role_empty_list(self): + # Test assigning permissions to a role with empty permission_ids list + mock_doc = MagicMock(spec=RoleDoc) + self.mock_handler.assign_permissions_to_role = AsyncMock(return_value=mock_doc) + result = await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', []) + assert result == mock_doc + self.mock_handler.assign_permissions_to_role.assert_awaited_once() + + async def test_assign_permissions_to_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during assign + self.mock_handler.assign_permissions_to_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.assign_permissions_to_role('507f1f77bcf86cd799439011', ['pid1']) + + async def test_delete_role_success(self): + # Test deleting a role successfully returns None + self.mock_handler.delete_role = AsyncMock(return_value=None) + result = await self.service.delete_role('507f1f77bcf86cd799439011') + assert result is None + self.mock_handler.delete_role.assert_awaited_once() + + async def test_delete_role_fail(self): + # Test that an exception is raised if the handler fails to delete a role + self.mock_handler.delete_role = AsyncMock(side_effect=RequestValidationError('error')) + with pytest.raises(RequestValidationError): + await self.service.delete_role('507f1f77bcf86cd799439011') + + async def test_delete_role_handler_unexpected_exception(self): + # Test handler raises unexpected exception during delete + self.mock_handler.delete_role = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.delete_role('507f1f77bcf86cd799439011') \ No newline at end of file diff --git a/apps/authentication/tests/unit_tests/backend/permission/user_role_handler/__init__.py b/apps/authentication/tests/unit_tests/backend/permission/user_role_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/user/__init__.py b/apps/authentication/tests/unit_tests/backend/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/user/user_management_service/__init__.py b/apps/authentication/tests/unit_tests/backend/user/user_management_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py b/apps/authentication/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py new file mode 100644 index 0000000..8787839 --- /dev/null +++ b/apps/authentication/tests/unit_tests/backend/user/user_management_service/test_user_management_service.py @@ -0,0 +1,172 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from backend.services.user.user_management_service import UserManagementService +from backend.models.user.models import UserAccountDoc +from backend.models.permission.models import UserRoleDoc +from backend.models.user.constants import NewUserMethod +from common.constants.region import UserRegion + +@pytest.fixture +def mock_handlers(): + with patch('backend.services.user.user_management_service.UserProfileHandler') as MockProfileHandler, \ + patch('backend.services.user.user_management_service.UserAuthHandler') as MockAuthHandler, \ + patch('backend.services.user.user_management_service.UserRoleHandler') as MockRoleHandler: + yield MockProfileHandler, MockAuthHandler, MockRoleHandler + +@pytest.mark.asyncio +class TestUserManagementService: + @pytest.fixture(autouse=True) + def setup(self, mock_handlers): + # Automatically set up a UserManagementService with mocked handlers for each test + MockProfileHandler, MockAuthHandler, MockRoleHandler = mock_handlers + self.mock_profile_handler = MockProfileHandler.return_value + self.mock_auth_handler = MockAuthHandler.return_value + self.mock_role_handler = MockRoleHandler.return_value + self.service = UserManagementService() + self.service.user_profile_handler = self.mock_profile_handler + self.service.user_auth_handler = self.mock_auth_handler + self.service.user_role_handler = self.mock_role_handler + + async def test_create_new_user_account_email(self): + # Test creating a new user account with EMAIL method + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.create_new_user_account = AsyncMock(return_value=mock_account) + result = await self.service.create_new_user_account(NewUserMethod.EMAIL, UserRegion.ZH_CN) + assert result == mock_account + self.mock_profile_handler.create_new_user_account.assert_awaited_once() + + async def test_create_new_user_account_mobile(self): + # Test creating a new user account with MOBILE method + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.create_new_user_account = AsyncMock(return_value=mock_account) + result = await self.service.create_new_user_account(NewUserMethod.MOBILE, UserRegion.ZH_CN) + assert result == mock_account + self.mock_profile_handler.create_new_user_account.assert_awaited_once() + + + async def test_create_new_user_account_handler_exception(self): + # Test handler exception is propagated when creating a new user account + self.mock_profile_handler.create_new_user_account = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.create_new_user_account(NewUserMethod.EMAIL, UserRegion.ZH_CN) + + async def test_initialize_new_user_data_email(self): + # Test initializing new user data with EMAIL method + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_auth_handler.save_email_auth_method.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_mobile(self): + # Test initializing new user data with MOBILE method + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE, mobile_number='123456') + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_other(self): + # Test initializing new user data with unsupported method returns False + result = await self.service.initialize_new_user_data('uid', 'OTHER') + assert result is False + + async def test_initialize_new_user_data_email_handler_exception(self): + # Test exception in create_basic_profile is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + + async def test_initialize_new_user_data_mobile_handler_exception(self): + # Test exception in create_basic_profile for MOBILE is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE, mobile_number='123456') + + async def test_initialize_new_user_data_email_missing_email(self): + # Test initializing new user data with EMAIL method but missing email_address + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + # Should still call with None, but may not be valid in real logic + result = await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL) + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_auth_handler.save_email_auth_method.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_mobile_missing_mobile(self): + # Test initializing new user data with MOBILE method but missing mobile_number + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock() + result = await self.service.initialize_new_user_data('uid', NewUserMethod.MOBILE) + assert result is True + self.mock_profile_handler.create_basic_profile.assert_awaited_once() + self.mock_profile_handler.create_provider_profile.assert_awaited_once() + + async def test_initialize_new_user_data_provider_profile_exception(self): + # Test exception in create_provider_profile is propagated + self.mock_profile_handler.create_basic_profile = AsyncMock() + self.mock_auth_handler.save_email_auth_method = AsyncMock() + self.mock_profile_handler.create_provider_profile = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.initialize_new_user_data('uid', NewUserMethod.EMAIL, email_address='a@b.com') + + async def test_get_account_by_id(self): + # Test getting account by user id + mock_account = MagicMock(spec=UserAccountDoc) + self.mock_profile_handler.get_account_by_id = AsyncMock(return_value=mock_account) + result = await self.service.get_account_by_id('uid') + assert result == mock_account + self.mock_profile_handler.get_account_by_id.assert_awaited_once_with('uid') + + async def test_get_account_by_id_none(self): + # Test getting account by user id returns None if not found + self.mock_profile_handler.get_account_by_id = AsyncMock(return_value=None) + result = await self.service.get_account_by_id('uid') + assert result is None + self.mock_profile_handler.get_account_by_id.assert_awaited_once_with('uid') + + async def test_get_account_by_id_exception(self): + # Test exception in get_account_by_id is propagated + self.mock_profile_handler.get_account_by_id = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.get_account_by_id('uid') + + async def test_assign_roles_to_user(self): + # Test assigning roles to user + mock_user_role = MagicMock(spec=UserRoleDoc) + self.mock_role_handler.assign_roles_to_user = AsyncMock(return_value=mock_user_role) + result = await self.service.assign_roles_to_user('uid', ['rid1', 'rid2']) + assert result == mock_user_role + self.mock_role_handler.assign_roles_to_user.assert_awaited_once_with('uid', ['rid1', 'rid2']) + + async def test_assign_roles_to_user_handler_exception(self): + # Test exception in assign_roles_to_user is propagated + self.mock_role_handler.assign_roles_to_user = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.assign_roles_to_user('uid', ['rid1', 'rid2']) + + async def test_get_role_and_permission_by_user_id(self): + # Test getting role and permission by user id + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(return_value=(['role1'], ['perm1'])) + result = await self.service.get_role_and_permission_by_user_id('uid') + assert result == (['role1'], ['perm1']) + self.mock_role_handler.get_role_and_permission_by_user_id.assert_awaited_once_with('uid') + + async def test_get_role_and_permission_by_user_id_empty(self): + # Test getting role and permission by user id returns empty lists if no roles/permissions + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(return_value=([], [])) + result = await self.service.get_role_and_permission_by_user_id('uid') + assert result == ([], []) + self.mock_role_handler.get_role_and_permission_by_user_id.assert_awaited_once_with('uid') + + async def test_get_role_and_permission_by_user_id_exception(self): + # Test exception in get_role_and_permission_by_user_id is propagated + self.mock_role_handler.get_role_and_permission_by_user_id = AsyncMock(side_effect=Exception('db error')) + with pytest.raises(Exception): + await self.service.get_role_and_permission_by_user_id('uid') \ No newline at end of file diff --git a/apps/authentication/webapi/__init__.py b/apps/authentication/webapi/__init__.py new file mode 100644 index 0000000..e69de29 From e2f51d09da75da530b383941a4fb85819e7d5db4 Mon Sep 17 00:00:00 2001 From: icecheng Date: Tue, 12 Aug 2025 11:17:52 +0800 Subject: [PATCH 17/19] feat(role): update docs --- apps/authentication/backend/business/signin_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/authentication/backend/business/signin_manager.py b/apps/authentication/backend/business/signin_manager.py index 08faf16..fb6adbb 100644 --- a/apps/authentication/backend/business/signin_manager.py +++ b/apps/authentication/backend/business/signin_manager.py @@ -45,7 +45,7 @@ class SignInManager: Args: email (str): email address code (str): auth code to be verified - host (str): the host address by which the client access the frontend service + host (str): the host address by which the client access the frontend service, for detecting UserRegion time_zone (str, optional): timezone of the frontend service Returns: @@ -124,6 +124,7 @@ class SignInManager: warning="The auth code is invalid.", properties={"email": email, "code": code}, ) + # TODO refactor this to reduce None return UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None, None async def signin_with_email_and_password( @@ -138,6 +139,7 @@ class SignInManager: if is_new_user: # cannot find the email address + # TODO refactor this to reduce None return (UserLoginAction.VERIFY_EMAIL_WITH_AUTH_CODE, None, None, None, None, None) else: if await self.user_auth_service.is_password_reset_required(user_id): @@ -182,6 +184,7 @@ class SignInManager: else: # ask user to input password again. # TODO: we need to limit times of user to input the wrong password + # TODO refactor this to reduce None return ( UserLoginAction.EXISTING_USER_PASSWORD_REQUIRED, None, From fcee95d6094ba585727dedb30550b3a3112f9521 Mon Sep 17 00:00:00 2001 From: dongli Date: Mon, 11 Aug 2025 21:09:25 -0700 Subject: [PATCH 18/19] modify PR template --- .gitea/PULL_REQUEST_TEMPLATE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d716f17 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +### PR Checklist + +- [ ] PR has a clear description +- [ ] Code is tested can work correctly +- [ ] PR link is added to corresponding tasks +- [ ] No hardcoded secrets or credentials +- [ ] Appropriate documentation is updated + +- [ ] New code is covered by unit tests +- [ ] Unit tests pass From fc9bc1a063bc7f94a17991aa4b0dba4cb580720d Mon Sep 17 00:00:00 2001 From: dongli Date: Mon, 11 Aug 2025 21:20:28 -0700 Subject: [PATCH 19/19] add dummy change --- .gitea/PULL_REQUEST_TEMPLATE.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index d716f17..e69de29 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -1,10 +0,0 @@ -### PR Checklist - -- [ ] PR has a clear description -- [ ] Code is tested can work correctly -- [ ] PR link is added to corresponding tasks -- [ ] No hardcoded secrets or credentials -- [ ] Appropriate documentation is updated - -- [ ] New code is covered by unit tests -- [ ] Unit tests pass