diff --git a/ruoyi-fastapi-backend/cli/context.py b/ruoyi-fastapi-backend/cli/context.py index adbfab968..9fa861d42 100644 --- a/ruoyi-fastapi-backend/cli/context.py +++ b/ruoyi-fastapi-backend/cli/context.py @@ -11,7 +11,8 @@ class CliContext: """ CLI 运行上下文。 - env: 当前命令使用的环境名称 + env: 当前加载的配置文件选择名 + app_env: 已加载配置声明的实际应用环境 output: 结果输出格式 operator: 操作者标识,后续用于审计 allow_prod: 是否显式允许生产环境执行危险命令 @@ -25,6 +26,7 @@ class CliContext: allow_prod: bool = False yes: bool = False dry_run: bool = False + app_env: str | None = None class CliContextBuilder: @@ -53,8 +55,12 @@ def build( :param dry_run: 是否执行演练模式 :return: 构建完成的 CLI 上下文 """ + # 必须与实际资源配置使用同一来源,命令参数默认值不能覆盖已加载的环境。 + from config.env import AppConfig, get_config # noqa: PLC0415 - 保留 CLI 启动时的惰性配置加载 + return CliContext( - env=env, + env=get_config.run_env, + app_env=AppConfig.app_env, output=output, allow_prod=allow_prod, yes=yes, @@ -66,7 +72,12 @@ def build( EnvOption = Annotated[ str, - typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values), + typer.Option( + '--env', + envvar='APP_ENV', + help='运行环境配置名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values, + ), ] OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')] AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')] diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py index 47aba0f43..a8d1895f9 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py @@ -165,9 +165,8 @@ def _context( yes: bool = False, dry_run: bool = False, ) -> CliContext: - # config.env 可能在应用日志策略时首次导入,因此必须先设置命令选择的环境。 """ - 先设置命令环境,再应用危险操作保护并解析操作者。 + 使用统一执行上下文应用危险操作保护并解析操作者。 :param env: 当前命令运行环境 :param output: 输出格式 @@ -177,7 +176,6 @@ def _context( :param dry_run: 是否仅预演而不写入发布状态 :return: 完成环境与操作者设置的 CLI 上下文 """ - os.environ['APP_ENV'] = env if command_name and not dry_run: ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, dry_run, command_name=command_name) else: diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index 2bad51f94..75cc1cbce 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -122,7 +122,8 @@ def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None try: confirmed = typer.confirm( - f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}', + f'确认执行危险命令 `{command_name}` 吗? 配置:{ctx.env},' + f'应用环境:{ctx.app_env or ctx.env}{"(dry-run)" if ctx.dry_run else ""}', default=False, ) except (click.Abort, EOFError, KeyboardInterrupt): @@ -173,7 +174,7 @@ def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult :return: 拒绝结果或 None """ command_name = rule.command_name - if ctx.env == 'prod' and not ctx.allow_prod: + if 'prod' in {ctx.env.strip().lower(), (ctx.app_env or '').strip().lower()} and not ctx.allow_prod: return self.result_builder.build_guard_reject_result( f'生产环境默认禁止直接执行危险命令:{command_name}', '如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes', diff --git a/ruoyi-fastapi-backend/cli/runtime/job/service.py b/ruoyi-fastapi-backend/cli/runtime/job/service.py index 6f6c38e4b..705e46cd8 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/service.py @@ -211,8 +211,10 @@ async def run_with_scheduler_context( 'operation': operation, 'operationLabel': operation_metadata['operationLabel'], 'targetStatus': status, - 'message': operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败', + 'message': result.message + or (operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败'), 'serviceMessage': result.message, + 'result': result.result, } async def run_job_once(self, job_id: int) -> dict[str, Any]: diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py index 6353d0c57..a5db0caee 100644 --- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py @@ -11,7 +11,10 @@ import httpx from async_lru import alru_cache from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, ORJSONResponse, UJSONResponse +from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from starlette.status import HTTP_200_OK from typing_extensions import ParamSpec from user_agents import parse @@ -20,8 +23,13 @@ from common.enums import BusinessType from config.env import AppConfig from exceptions.exception import ( + AuthException, FileRangeNotSatisfiableException, LoginException, + ModelValidatorException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, ServiceException, ServiceWarning, ) @@ -29,6 +37,7 @@ from module_admin.service.log_service import LogQueueService from utils.client_ip_util import ClientIPUtil from utils.dependency_util import DependencyUtil +from utils.exception_util import ExceptionUtil from utils.log_util import LogSanitizer, logger from utils.response_util import ResponseUtil from utils.time_util import TimezoneUtil @@ -193,79 +202,94 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: oper_time = TimezoneUtil.utc_now() # 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息 login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs) + raised_exception = None try: # 调用原始函数 result = await func(*args, **kwargs) - except (LoginException, ServiceWarning) as e: - logger.warning(e.message) - result = ResponseUtil.failure(data=e.data, msg=e.message) - except ServiceException as e: - logger.error(e.message) - result = ResponseUtil.error(data=e.data, msg=e.message) - except FileRangeNotSatisfiableException: - raise + except ( + AuthException, + PermissionException, + LoginException, + ServiceException, + ServiceWarning, + ModelValidatorException, + FieldValidationError, + FileRangeNotSatisfiableException, + HTTPException, + RequestValidationError, + OAuthProtocolException, + OidcInteractionException, + ) as e: + # 已知异常仅构造审计摘要,实际响应仍交由全局异常处理器生成。 + raised_exception = e + result = ResponseUtil.error(msg=getattr(e, 'message', None) or str(e)) except Exception as e: - logger.exception(e) - result = ResponseUtil.error(msg=str(e)) - # 获取请求耗时 - cost_time = float(time.perf_counter() - start_time) * 1000 - # 判断请求是否来自api文档 - request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) - # 根据响应结果的类型使用不同的方法获取响应结果参数 - sanitized_result_dict = LogSanitizer.sanitize_data( - self._get_result_dict(result, request_from_swagger, request_from_redoc) - ) - json_result = self._build_log_text( - sanitized_result_dict, - self.response_log_mode, - self.response_include_fields, - self.response_exclude_fields, - payload_kind='response', - ) - # 日志表返回参数字段长度最大为2000,因此在此处判断长度 - json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') - # 根据响应结果获取响应状态及异常信息 - status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) - # 根据日志类型向对应的日志表插入数据 - if self.log_type == 'login': - # 登录请求来自于api文档时不记录登录日志,其余情况则记录 - if request_from_swagger or request_from_redoc: - pass + result = ExceptionUtil.handle_unexpected_exception(e) + try: + # 获取请求耗时 + cost_time = float(time.perf_counter() - start_time) * 1000 + # 判断请求是否来自api文档 + request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) + # 根据响应结果的类型使用不同的方法获取响应结果参数 + sanitized_result_dict = LogSanitizer.sanitize_data( + self._get_result_dict(result, request_from_swagger, request_from_redoc) + ) + json_result = self._build_log_text( + sanitized_result_dict, + self.response_log_mode, + self.response_include_fields, + self.response_exclude_fields, + payload_kind='response', + ) + # 日志表返回参数字段长度最大为2000,因此在此处判断长度 + json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') + # 根据响应结果获取响应状态及异常信息 + status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) + # 根据日志类型向对应的日志表插入数据 + if self.log_type == 'login': + # 登录请求来自于api文档时不记录登录日志,其余情况则记录 + if request_from_swagger or request_from_redoc: + pass + else: + user = kwargs.get('form_data') + login_log.update( + { + 'loginTime': oper_time, + 'userName': user.username, + 'status': str(status), + 'msg': sanitized_result_dict.get('msg') or '', + } + ) + + await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) else: - user = kwargs.get('form_data') - login_log.update( - { - 'loginTime': oper_time, - 'userName': user.username, - 'status': str(status), - 'msg': sanitized_result_dict.get('msg') or '', - } + current_user = RequestContext.get_current_user() + oper_name = current_user.user.user_name + dept_name = current_user.user.dept.dept_name if current_user.user.dept else None + operation_log = OperLogModel( + title=self.title, + businessType=self.business_type, + method=func_path, + requestMethod=request_method, + operatorType=operator_type, + operName=oper_name, + deptName=dept_name, + operUrl=oper_url, + operIp=oper_ip, + operLocation=oper_location, + operParam=oper_param, + jsonResult=json_result, + status=status, + errorMsg=error_msg, + operTime=oper_time, + costTime=int(cost_time), ) + await LogQueueService.enqueue_operation_log(request, operation_log, func_path) - await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) - else: - current_user = RequestContext.get_current_user() - oper_name = current_user.user.user_name - dept_name = current_user.user.dept.dept_name if current_user.user.dept else None - operation_log = OperLogModel( - title=self.title, - businessType=self.business_type, - method=func_path, - requestMethod=request_method, - operatorType=operator_type, - operName=oper_name, - deptName=dept_name, - operUrl=oper_url, - operIp=oper_ip, - operLocation=oper_location, - operParam=oper_param, - jsonResult=json_result, - status=status, - errorMsg=error_msg, - operTime=oper_time, - costTime=int(cost_time), - ) - await LogQueueService.enqueue_operation_log(request, operation_log, func_path) + finally: + # 即使审计写入失败,也不能覆盖原有认证或协议异常。 + if raised_exception is not None: + raise raised_exception return result diff --git a/ruoyi-fastapi-backend/common/aspect/data_scope.py b/ruoyi-fastapi-backend/common/aspect/data_scope.py index 80d83048b..0d3384101 100644 --- a/ruoyi-fastapi-backend/common/aspect/data_scope.py +++ b/ruoyi-fastapi-backend/common/aspect/data_scope.py @@ -1,5 +1,5 @@ from fastapi import Depends, Request, params -from sqlalchemy import ColumnElement, func, or_, select +from sqlalchemy import ColumnElement, false, func, or_, select, true from common.context import RequestContext from config.database import Base @@ -39,14 +39,15 @@ def __init__( def __call__(self, request: Request) -> ColumnElement: DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项') current_user = RequestContext.get_current_user() + if current_user.user.admin: + return true() user_id = current_user.user.user_id dept_id = current_user.user.dept_id - custom_data_scope_role_id_list = [ - item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM - ] + roles = [role for role in (current_user.user.role or []) if role.status == '0' and role.del_flag == '0'] + custom_data_scope_role_id_list = [item.role_id for item in roles if item.data_scope == self.DATA_SCOPE_CUSTOM] param_sql_list = [] - for role in current_user.user.role: - if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL: + for role in roles: + if role.data_scope == self.DATA_SCOPE_ALL: param_sql_list = [True] break if role.data_scope == self.DATA_SCOPE_CUSTOM: @@ -91,7 +92,7 @@ def __call__(self, request: Request) -> ColumnElement: else: param_sql_list.append(False) param_sql_list = list(dict.fromkeys(param_sql_list)) - param_sql = or_(*param_sql_list) + param_sql = or_(false(), *param_sql_list) return param_sql diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py index 996fb0d36..7bad197dc 100644 --- a/ruoyi-fastapi-backend/exceptions/handle.py +++ b/ruoyi-fastapi-backend/exceptions/handle.py @@ -1,10 +1,10 @@ from urllib.parse import urlsplit from fastapi import FastAPI, Request, Response, status -from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse as FastAPIJSONResponse from fastapi.responses import RedirectResponse from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from exceptions.exception import ( AuthException, @@ -17,6 +17,7 @@ ServiceException, ServiceWarning, ) +from utils.exception_util import ExceptionUtil from utils.log_util import logger from utils.oidc_util import OidcUtil from utils.response_util import JSONResponse, ResponseUtil, jsonable_encoder @@ -118,14 +119,15 @@ async def file_range_not_satisfiable_exception_handler( @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException) -> Response: return JSONResponse( - content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), status_code=exc.status_code + content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), + status_code=exc.status_code, + headers=exc.headers, ) # 处理其他异常 @app.exception_handler(Exception) async def exception_handler(request: Request, exc: Exception) -> Response: - logger.exception(exc) - return ResponseUtil.error(msg=str(exc)) + return ExceptionUtil.handle_unexpected_exception(exc) def _build_oauth_redirect(exc: OAuthProtocolException) -> Response: diff --git a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py index bfb55f0b4..ab5399331 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py @@ -83,7 +83,10 @@ async def add_system_dept( add_dept: DeptModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], ) -> Response: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, add_dept.parent_id, data_scope_sql) add_dept.create_by = current_user.user.user_name add_dept.update_by = current_user.user.user_name add_dept_result = await DeptService.add_dept_services(query_db, add_dept) @@ -111,6 +114,9 @@ async def edit_system_dept( ) -> Response: if not current_user.user.admin: await DeptService.check_dept_data_scope_services(query_db, edit_dept.dept_id, data_scope_sql) + dept_info = await DeptService.dept_detail_services(query_db, edit_dept.dept_id) + if edit_dept.parent_id is not None and edit_dept.parent_id != dept_info.parent_id: + await DeptService.check_dept_data_scope_services(query_db, edit_dept.parent_id, data_scope_sql) edit_dept.update_by = current_user.user.user_name edit_dept_result = await DeptService.edit_dept_services(query_db, edit_dept) logger.info(edit_dept_result.message) diff --git a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py index a720a5aba..434ae9ba8 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py @@ -152,6 +152,26 @@ async def edit_system_user( dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], ) -> Response: + # 普通编辑只接收管理表单字段,密码与内部操作类型由专用接口处理。 + edit_user = EditUserModel( + **edit_user.model_dump( + by_alias=True, + exclude_unset=True, + include={ + 'user_id', + 'dept_id', + 'user_name', + 'nick_name', + 'email', + 'phonenumber', + 'sex', + 'status', + 'remark', + }, + ), + roleIds=edit_user.role_ids or [], + postIds=edit_user.post_ids or [], + ) await UserService.check_user_allowed_services(edit_user) if not current_user.user.admin: await UserService.check_user_data_scope_services(query_db, edit_user.user_id, user_data_scope_sql) diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py index 055ffb54f..030a67976 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py @@ -1,11 +1,12 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size, Xss from common.mixin import DateRangeQueryMixin from common.types import ApiUtcDateTime +from utils.security_util import SecurityUtil class NoticeModel(BaseModel): @@ -26,6 +27,18 @@ class NoticeModel(BaseModel): update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') + @field_validator('notice_content') + @classmethod + def sanitize_content(cls, value: bytes | None) -> bytes | None: + """ + 清洗公告正文,同时保护 API 写入及历史详情读取。 + + :param value: 公告正文二进制内容 + :return: 清洗后的正文 + """ + cleaned = SecurityUtil.sanitize_html(value) + return cleaned.encode('utf-8') if cleaned is not None else None + @Xss(field_name='notice_title', message='公告标题不能包含脚本字符') @NotBlank(field_name='notice_title', message='公告标题不能为空') @Size(field_name='notice_title', min_length=0, max_length=50, message='公告标题不能超过50个字符') diff --git a/ruoyi-fastapi-backend/module_admin/service/dept_service.py b/ruoyi-fastapi-backend/module_admin/service/dept_service.py index 17a20387e..41d8ac68c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/dept_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/dept_service.py @@ -113,7 +113,9 @@ async def add_dept_services(cls, query_db: AsyncSession, page_object: DeptModel) """ if not await cls.check_dept_name_unique_services(query_db, page_object): raise ServiceException(message=f'新增部门{page_object.dept_name}失败,部门名称已存在') - parent_info = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) + parent_info = await DeptDao.get_dept_detail_by_id(query_db, page_object.parent_id) + if not parent_info: + raise ServiceException(message='上级部门不存在') if parent_info.status != CommonConstant.DEPT_NORMAL: raise ServiceException(message=f'部门{parent_info.dept_name}停用,不允许新增') page_object.ancestors = f'{parent_info.ancestors},{page_object.parent_id}' @@ -134,6 +136,11 @@ async def edit_dept_services(cls, query_db: AsyncSession, page_object: DeptModel :param page_object: 编辑部门对象 :return: 编辑部门校验结果 """ + old_dept = await DeptDao.get_dept_detail_by_id(query_db, page_object.dept_id) + if not old_dept: + raise ServiceException(message='部门不存在') + if page_object.parent_id is None: + page_object.parent_id = old_dept.parent_id if not await cls.check_dept_name_unique_services(query_db, page_object): raise ServiceException(message=f'修改部门{page_object.dept_name}失败,部门名称已存在') if page_object.dept_id == page_object.parent_id: @@ -143,20 +150,26 @@ async def edit_dept_services(cls, query_db: AsyncSession, page_object: DeptModel and (await DeptDao.count_normal_children_dept_dao(query_db, page_object.dept_id)) > 0 ): raise ServiceException(message=f'修改部门{page_object.dept_name}失败,该部门包含未停用的子部门') - new_parent_dept = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) - old_dept = await DeptDao.get_dept_by_id(query_db, page_object.dept_id) + # 根部门使用虚拟父节点 0,其他父部门必须存在且不能是当前部门的后代。 + new_ancestors = '0' + if page_object.parent_id != 0: + new_parent_dept = await DeptDao.get_dept_detail_by_id(query_db, page_object.parent_id) + if not new_parent_dept: + raise ServiceException(message='上级部门不存在') + if str(page_object.dept_id) in (new_parent_dept.ancestors or '').split(','): + raise ServiceException(message='上级部门不能是当前部门的子部门') + new_ancestors = f'{new_parent_dept.ancestors},{new_parent_dept.dept_id}' + # 祖级列表只根据服务端部门关系生成,不接受客户端直接修改。 + page_object.ancestors = new_ancestors try: - if new_parent_dept and old_dept: - new_ancestors = f'{new_parent_dept.ancestors},{new_parent_dept.dept_id}' - old_ancestors = old_dept.ancestors - page_object.ancestors = new_ancestors - await cls.update_dept_children(query_db, page_object.dept_id, new_ancestors, old_ancestors) + if new_ancestors != old_dept.ancestors: + await cls.update_dept_children(query_db, page_object.dept_id, new_ancestors, old_dept.ancestors) edit_dept = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) await DeptDao.edit_dept_dao(query_db, edit_dept) if ( page_object.status == CommonConstant.DEPT_NORMAL and page_object.ancestors - and page_object.ancestors != 0 + and page_object.ancestors != '0' ): await cls.update_parent_dept_status_normal(query_db, page_object) await query_db.commit() diff --git a/ruoyi-fastapi-backend/module_admin/service/menu_service.py b/ruoyi-fastapi-backend/module_admin/service/menu_service.py index 6909a894c..9971611e9 100644 --- a/ruoyi-fastapi-backend/module_admin/service/menu_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/menu_service.py @@ -163,20 +163,33 @@ async def edit_menu_services(cls, query_db: AsyncSession, page_object: MenuModel 编辑菜单信息service :param query_db: orm对象 - :param page_object: 编辑部门对象 + :param page_object: 编辑菜单对象 :return: 编辑菜单校验结果 """ - edit_menu = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) menu_info = await cls.menu_detail_services(query_db, page_object.menu_id) if menu_info.menu_id: + if page_object.parent_id is None: + page_object.parent_id = menu_info.parent_id if not await cls.check_menu_name_unique_services(query_db, page_object): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,菜单名称已存在') if page_object.is_frame == MenuConstant.YES_FRAME and not StringUtil.is_http(page_object.path): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,地址必须以http(s)://开头') if page_object.menu_id == page_object.parent_id: raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,上级菜单不能选择自己') + # 沿父级链校验,防止选择子菜单或接入已有的循环关系。 + parent_id = page_object.parent_id + visited_ids = {page_object.menu_id} + while parent_id != MenuConstant.ROOT_ID: + if parent_id in visited_ids: + raise ServiceException(message='上级菜单存在循环关系') + visited_ids.add(parent_id) + parent_menu = await MenuDao.get_menu_detail_by_id(query_db, parent_id) + if not parent_menu: + raise ServiceException(message='上级菜单不存在') + parent_id = parent_menu.parent_id if not await cls.check_route_config_unique_services(query_db, page_object): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,路由名称或地址已存在') + edit_menu = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) try: await MenuDao.edit_menu_dao(query_db, edit_menu) await query_db.commit() diff --git a/ruoyi-fastapi-backend/module_admin/service/notice_service.py b/ruoyi-fastapi-backend/module_admin/service/notice_service.py index 0f09a92a4..4d619516c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/notice_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/notice_service.py @@ -15,6 +15,7 @@ NoticeTopResponseModel, ) from utils.common_util import CamelCaseUtil +from utils.security_util import SecurityUtil class NoticeService: @@ -38,6 +39,10 @@ async def get_notice_list_services( """ notice_list_result = await NoticeDao.get_notice_list(query_db, query_object, is_page) + rows = notice_list_result.rows if isinstance(notice_list_result, PageModel) else notice_list_result + for row in rows: + if 'noticeContent' in row: + row['noticeContent'] = SecurityUtil.sanitize_html(row['noticeContent']) return notice_list_result @classmethod diff --git a/ruoyi-fastapi-backend/requirements-pg.txt b/ruoyi-fastapi-backend/requirements-pg.txt index 384b38cbd..c512bc7e4 100644 --- a/ruoyi-fastapi-backend/requirements-pg.txt +++ b/ruoyi-fastapi-backend/requirements-pg.txt @@ -6,6 +6,7 @@ asyncpg==0.32.0 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/requirements.txt b/ruoyi-fastapi-backend/requirements.txt index 4660e6e08..25c6600db 100644 --- a/ruoyi-fastapi-backend/requirements.txt +++ b/ruoyi-fastapi-backend/requirements.txt @@ -6,6 +6,7 @@ asyncmy==0.2.16 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py new file mode 100644 index 000000000..fa6f640bd --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py @@ -0,0 +1,96 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from cli.context import EnvOption + +BACKEND_DIR = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + ('external_env', 'selected', 'file_env', 'expected_selector', 'expected_app', 'rejected'), + [ + (None, None, 'dev', 'dev', 'dev', False), + ('prod', None, 'dev', 'prod', 'prod', True), + (None, 'prod', 'prod', 'prod', 'prod', True), + (None, 'dockermy', 'prod', 'dockermy', 'prod', True), + (None, 'dockerpg', 'prod', 'dockerpg', 'prod', True), + ('prod', 'dev', 'dev', 'dev', 'prod', True), + ('dev', 'prod', 'prod', 'prod', 'dev', True), + ], +) +def test_guard_uses_loaded_environment_without_resource_access( + tmp_path: Path, + external_env: str | None, + selected: str | None, + file_env: str, + expected_selector: str, + expected_app: str, + rejected: bool, +) -> None: + (tmp_path / f'.env.{expected_selector}').write_text(f'APP_ENV={file_env}\n', encoding='utf-8') + script = """ +import json +from cli.context import CLI_CONTEXT_BUILDER +from cli.guards import DEFAULT_DANGEROUS_COMMAND_GUARD, DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY +ctx = CLI_CONTEXT_BUILDER.build('dev', 'json', False, True, False) +rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('job run-once') +result = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +ctx.allow_prod = True +allowed = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +print(json.dumps({'env': ctx.env, 'appEnv': ctx.app_env, 'rejected': result is not None, 'allowed': allowed is None})) +""" + process_env = {key: value for key, value in os.environ.items() if key != 'APP_ENV' and not key.startswith('OIDC_')} + process_env['PYTHONPATH'] = str(BACKEND_DIR) + process_env['DB_DEFAULT_SOURCE'] = 'master' + process_env['DB_SOURCES'] = json.dumps( + { + 'master': { + 'db_type': 'mysql', + 'db_host': '127.0.0.1', + 'db_port': 1, + 'db_username': 'test', + 'db_password': 'test', + 'db_database': 'isolated', + } + } + ) + if external_env: + process_env['APP_ENV'] = external_env + completed = subprocess.run( + [sys.executable, '-c', script, *(['--env', selected] if selected else [])], + cwd=tmp_path, + env=process_env, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + 'env': expected_selector, + 'appEnv': expected_app, + 'rejected': rejected, + 'allowed': True, + } + + +def test_env_option_uses_external_default_and_preserves_explicit_choice(monkeypatch: pytest.MonkeyPatch) -> None: + app = typer.Typer() + selected = [] + + @app.command() + def run(env: EnvOption = 'dev') -> None: + selected.append(env) + + monkeypatch.setenv('APP_ENV', 'prod') + runner = CliRunner() + assert runner.invoke(app, []).exit_code == 0 + assert runner.invoke(app, ['--env', 'dockermy']).exit_code == 0 + assert selected == ['prod', 'dockermy'] diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py index 707dd7e1d..6218399a1 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -274,7 +274,6 @@ def __init__(self, events: list[str]) -> None: self.events = events def build_regular(self, env: str, output: str, allow_prod: bool, yes: bool, dry_run: bool) -> CliContext: - assert os.environ['APP_ENV'] == env self.events.append('context') return CliContext(env=env, output=output, allow_prod=allow_prod, yes=yes, dry_run=dry_run) @@ -310,11 +309,15 @@ def quiet_context_factory() -> CliContextFactory: ) -def test_production_mutations_are_guarded_before_loading_runtime() -> None: +def test_production_mutations_are_guarded_before_loading_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ 验证生产写入保护先于运行时加载执行。 """ + env_module = importlib.import_module('config.env') + monkeypatch.setattr(env_module.get_config, 'run_env', 'prod') + monkeypatch.setattr(env_module.AppConfig, 'app_env', 'prod') + def forbidden_config() -> None: pytest.fail('rejected CLI must not construct runtime') @@ -340,16 +343,17 @@ def forbidden_config() -> None: assert json.loads(result.stdout)['ok'] is False -def test_dry_run_is_readonly_and_environment_precedes_runtime() -> None: +def test_dry_run_is_readonly_and_environment_precedes_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ - 验证预演保持只读,且先设置环境再创建运行时。 + 验证预演保持只读,且不会用配置选择名覆盖外部应用环境。 """ + monkeypatch.setenv('APP_ENV', 'dev') events: list[str] = [] execution = RecordingExecution() def config() -> object: assert events == ['context'] - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' events.append('config') return object() diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py index 96c5395d5..5b9be51fa 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py @@ -146,7 +146,7 @@ def test_wait_outputs_exactly_one_final_json_and_preserves_service_exit_semantic timeout_seconds=0.0, poll_interval_seconds=0.25, ) - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' @pytest.mark.parametrize( diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py index 6da16e82c..2f3b3c6f2 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py @@ -1,8 +1,14 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + import pytest +from cli.output import OutputRenderer from cli.runtime.job import JobRuntimeService from cli.runtime.job.gateway import JobInfrastructureGateway from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport +from common.vo import CrudResponseModel MISSING_JOB_ID = 99 @@ -172,3 +178,39 @@ def _fake_get_job_service() -> FakeJobService: assert payload['ok'] is False assert payload['jobId'] == MISSING_JOB_ID assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['run-once', 'pause', 'resume']) +async def test_job_operation_retains_execution_and_sync_results( + operation: str, capsys: pytest.CaptureFixture[str] +) -> None: + result = ( + {'executionId': 'execution-123', 'status': 'pending'} + if operation == 'run-once' + else {'saved': True, 'synced': False, 'jobs': [{'jobId': 7, 'syncStatus': 'pending'}]} + ) + response = CrudResponseModel(is_success=True, message='已提交,等待调度器处理', result=result) + job_service = SimpleNamespace( + execute_job_once_services=AsyncMock(return_value=response), + edit_job_services=AsyncMock(return_value=response), + ) + gateway = MagicMock() + gateway.get_async_session_local.return_value = MagicMock(return_value=AsyncMock()) + gateway.get_redis_util.return_value.create_redis_pool = AsyncMock() + gateway.get_scheduler_manager.return_value.init_system_scheduler = AsyncMock() + gateway.get_job_service.return_value = job_service + scheduler_support = SimpleNamespace(close_scheduler_context=AsyncMock()) + service = JobRuntimeService(infrastructure_gateway=gateway, scheduler_support=scheduler_support) + payload = await service.run_with_scheduler_context(operation, 7) + assert payload['result'] == result + assert payload['message'] == response.message + OutputRenderer().emit_output(payload, 'json') + assert json.loads(capsys.readouterr().out)['result'] == result + text = OutputRenderer().render_text_output(payload) + if operation == 'run-once': + assert 'execution-123' in text + assert 'pending' in text + else: + assert 'saved' in text and 'synced' in text + scheduler_support.close_scheduler_context.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/common/test_data_scope.py b/ruoyi-fastapi-backend/tests/common/test_data_scope.py new file mode 100644 index 000000000..4d4e30b4c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_data_scope.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace + +import pytest +from fastapi import Request +from sqlalchemy import Column, Integer, MetaData, Table, create_engine, select +from sqlalchemy.orm import registry + +from common.aspect.data_scope import GetDataScope +from common.context import RequestContext +from module_admin.entity.vo.role_vo import RoleModel +from utils.dependency_util import DependencyUtil + + +@pytest.mark.parametrize( + ('roles', 'admin', 'expected'), + [ + ([], False, []), + ([('1', '1', '0')], False, []), + ([('1', '0', '2')], False, []), + ([(None, '0', '0')], False, []), + ([('5', '0', '0')], False, [1]), + ([('3', '0', '0')], False, [1, 2]), + ([('3', '0', '0'), ('5', '0', '0')], False, [1, 2]), + ([('5', '0', '0'), ('1', '1', '0')], False, [1]), + ([('1', '0', '0')], False, [1, 2, 3]), + ([], True, [1, 2, 3]), + ], +) +def test_data_scope_filters_real_query( + monkeypatch: pytest.MonkeyPatch, roles: list[tuple], admin: bool, expected: list[int] +) -> None: + metadata = MetaData() + table = Table('records', metadata, Column('user_id', Integer, primary_key=True), Column('dept_id', Integer)) + mapper = registry() + + class Record: + pass + + mapper.map_imperatively(Record, table) + user = SimpleNamespace( + user_id=1, + dept_id=10, + admin=admin, + role=[ + RoleModel(roleId=index + 2, dataScope=scope, status=status, delFlag=deleted) + for index, (scope, status, deleted) in enumerate(roles) + ], + ) + monkeypatch.setattr(RequestContext, 'get_current_user', lambda: SimpleNamespace(user=user)) + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + condition = GetDataScope(Record)(Request({'type': 'http'})) + query = select(table.c.user_id).where(condition).order_by(table.c.user_id) + engine = create_engine('sqlite://') + metadata.create_all(engine) + try: + with engine.begin() as connection: + connection.execute( + table.insert(), + [ + {'user_id': 1, 'dept_id': 10}, + {'user_id': 2, 'dept_id': 10}, + {'user_id': 3, 'dept_id': 20}, + ], + ) + assert list(connection.scalars(query)) == expected + if not expected: + assert 'WHERE 0 = 1' in str(query.compile(engine)) + finally: + engine.dispose() + mapper.dispose() diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py new file mode 100644 index 000000000..29cbcc2e9 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py @@ -0,0 +1,111 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.testclient import TestClient + +from common.annotation.log_annotation import Log +from common.constant import HttpStatusConstant +from common.context import RequestContext +from common.enums import BusinessType +from exceptions.exception import ( + AuthException, + FileRangeNotSatisfiableException, + LoginException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, + ServiceException, +) +from exceptions.handle import handle_exception +from module_admin.service.log_service import LogQueueService +from utils.dependency_util import DependencyUtil + + +@pytest.fixture +def log_context(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + monkeypatch.setattr(Log, '_get_oper_location', AsyncMock(return_value='测试')) + monkeypatch.setattr( + RequestContext, + 'get_current_user', + lambda: SimpleNamespace( + user=SimpleNamespace(user_name='tester', dept=None), + ), + ) + monkeypatch.setattr(LogQueueService, 'enqueue_operation_log', AsyncMock()) + diagnostic_logger = MagicMock() + monkeypatch.setattr('utils.exception_util.logger', diagnostic_logger) + return diagnostic_logger + + +@pytest.mark.parametrize('decorated', [False, True]) +def test_unexpected_error_is_safe_and_correlates_with_server_log(log_context: MagicMock, decorated: bool) -> None: + app = FastAPI() + handle_exception(app) + error = RuntimeError('SELECT secret FROM users; password=private-marker /srv/private/config') + + async def endpoint(request: Request) -> None: + raise error + + app.get('/failure')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint) if decorated else endpoint) + response = TestClient(app, raise_server_exceptions=False).get('/failure') + payload = response.json() + assert payload['code'] == HttpStatusConstant.ERROR + assert payload['msg'] == '服务暂时不可用,请稍后重试' + assert 'private-marker' not in response.text + assert 'SELECT' not in response.text + assert '/srv/private' not in response.text + error_id = payload['data']['errorId'] + assert response.headers['X-Error-Id'] == error_id + log_context.bind.assert_called_once_with(error_id=error_id) + log_context.bind.return_value.opt.assert_called_once_with(exception=error) + + +@pytest.mark.parametrize( + 'exception_kind', ['auth', 'permission', 'http', 'oauth', 'redirect', 'oidc', 'login', 'service', 'range'] +) +@pytest.mark.parametrize('audit_fails', [False, True]) +def test_logged_known_errors_preserve_global_response( + log_context: MagicMock, monkeypatch: pytest.MonkeyPatch, exception_kind: str, audit_fails: bool +) -> None: + exceptions = { + 'auth': AuthException(message='令牌失效'), + 'permission': PermissionException(message='无权限'), + 'http': HTTPException(401, '凭证无效', headers={'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store'}), + 'oauth': OAuthProtocolException('invalid_client'), + 'redirect': OAuthProtocolException( + 'access_denied', redirect_uri='https://client.example/callback', redirect_uri_verified=True, state='opaque' + ), + 'oidc': OidcInteractionException(interaction_id='expired'), + 'login': LoginException(message='登录失败'), + 'service': ServiceException(message='操作失败'), + 'range': FileRangeNotSatisfiableException(20), + } + app = FastAPI() + handle_exception(app) + + async def endpoint(request: Request) -> None: + raise exceptions[exception_kind] + + app.get('/plain')(endpoint) + app.get('/logged')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint)) + if audit_fails: + monkeypatch.setattr( + LogQueueService, 'enqueue_operation_log', AsyncMock(side_effect=RuntimeError('queue unavailable')) + ) + client = TestClient(app, raise_server_exceptions=False) + plain = client.get('/plain', follow_redirects=False) + logged = client.get('/logged', follow_redirects=False) + assert logged.status_code == plain.status_code + for name in ['location', 'www-authenticate', 'cache-control', 'pragma', 'content-range']: + assert logged.headers.get(name) == plain.headers.get(name) + if plain.content: + expected, actual = plain.json(), logged.json() + expected.pop('time', None) + actual.pop('time', None) + assert actual == expected + else: + assert not logged.content + LogQueueService.enqueue_operation_log.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py b/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py new file mode 100644 index 000000000..63dbbdf77 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py @@ -0,0 +1,74 @@ +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from exceptions.exception import ServiceException +from module_admin.controller.dept_controller import add_system_dept, edit_system_dept +from module_admin.entity.vo.dept_vo import DeptModel +from module_admin.service.dept_service import DeptService + + +@pytest.mark.asyncio +async def test_add_dept_rejects_parent_outside_data_scope() -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=False, user_name='operator')) + parent_id = 200 + with ( + patch.object( + DeptService, + 'check_dept_data_scope_services', + AsyncMock(side_effect=ServiceException(message='没有权限访问部门数据')), + ) as check_scope, + patch.object(DeptService, 'add_dept_services', AsyncMock()) as add_dept, + pytest.raises(ServiceException), + ): + await inspect.unwrap(add_system_dept)( + object(), DeptModel(parentId=parent_id, deptName='新部门'), object(), current_user, object() + ) + assert check_scope.await_args.args[1] == parent_id + add_dept.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('parent_id', 'parent_changed'), [(100, False), (200, True)]) +async def test_edit_dept_checks_changed_parent_scope(parent_id: int, parent_changed: bool) -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=False, user_name='operator')) + query_db, scope = object(), object() + with ( + patch.object( + DeptService, + 'check_dept_data_scope_services', + AsyncMock(side_effect=[None, ServiceException(message='没有权限访问部门数据')]), + ) as check_scope, + patch.object(DeptService, 'dept_detail_services', AsyncMock(return_value=DeptModel(deptId=101, parentId=100))), + patch.object( + DeptService, 'edit_dept_services', AsyncMock(return_value=SimpleNamespace(message='更新成功')) + ) as edit_dept, + ): + args = (object(), DeptModel(deptId=101, parentId=parent_id), query_db, current_user, scope) + if parent_changed: + with pytest.raises(ServiceException): + await inspect.unwrap(edit_system_dept)(*args) + assert [call.args[1] for call in check_scope.await_args_list] == [101, 200] + edit_dept.assert_not_awaited() + else: + await inspect.unwrap(edit_system_dept)(*args) + check_scope.assert_awaited_once_with(query_db, 101, scope) + edit_dept.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_admin_add_dept_does_not_require_parent_data_scope() -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=True, user_name='admin')) + with ( + patch.object(DeptService, 'check_dept_data_scope_services', AsyncMock()) as check_scope, + patch.object( + DeptService, 'add_dept_services', AsyncMock(return_value=SimpleNamespace(message='新增成功')) + ) as add_dept, + ): + await inspect.unwrap(add_system_dept)( + object(), DeptModel(parentId=100, deptName='新部门'), object(), current_user, object() + ) + check_scope.assert_not_awaited() + add_dept.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py b/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py index 94ac54b2e..679f13925 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py +++ b/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py @@ -6,7 +6,11 @@ from pydantic import ValidationError from common.enums import PasswordCharacterType -from module_admin.controller.user_controller import change_system_user_profile_info, reset_system_user_pwd +from module_admin.controller.user_controller import ( + change_system_user_profile_info, + edit_system_user, + reset_system_user_pwd, +) from module_admin.entity.vo.user_vo import EditUserModel, UpdateUserProfileModel from module_admin.service.user_service import UserService from utils.pwd_util import PwdUtil @@ -116,3 +120,35 @@ async def test_update_user_profile_only_forwards_editable_fields() -> None: 'create_by', 'create_time', }.intersection(update_fields) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation_type', [None, 'pwd', 'status', 'avatar']) +async def test_edit_user_does_not_accept_password_or_internal_operation_type(operation_type: str | None) -> None: + """普通编辑权限不能调用密码重置路径或写入内部维护字段。""" + payload = EditUserModel( + userId=2, + deptId=100, + userName='tester', + nickName='新昵称', + roleIds=[2], + postIds=[3], + password='injected-password-hash', + type=operation_type, + delFlag='2', + loginIp='forged-ip', + createBy='forged-creator', + ) + current_user = SimpleNamespace(user=SimpleNamespace(admin=True, user_name='admin')) + with patch.object( + UserService, 'edit_user_services', AsyncMock(return_value=SimpleNamespace(message='更新成功')) + ) as edit_user: + await inspect.unwrap(edit_system_user)(object(), payload, object(), current_user, object(), object(), object()) + + update = edit_user.await_args.args[1] + fields = update.model_dump(exclude_unset=True) + assert not {'password', 'type', 'del_flag', 'login_ip', 'create_by'}.intersection(fields) + assert update.nick_name == '新昵称' + assert update.role_ids == [2] + assert update.post_ids == [3] + assert update.update_by == 'admin' diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py index 13da67bdf..a27e2bb1b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py @@ -1,20 +1,26 @@ from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, patch import pytest -from sqlalchemy import select +from sqlalchemy import event, select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from config.database import Base from exceptions.exception import ServiceException from module_admin.dao.dept_dao import DeptDao from module_admin.entity.do.dept_do import SysDept -from module_admin.entity.vo.dept_vo import DeptSortModel +from module_admin.entity.vo.dept_vo import DeptModel, DeptSortModel from module_admin.service.dept_service import DeptService async def _create_dept_table() -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: engine = create_async_engine('sqlite+aiosqlite:///:memory:') + + @event.listens_for(engine.sync_engine, 'connect') + def register_find_in_set(connection: Any, _record: Any) -> None: + connection.create_function('find_in_set', 2, lambda value, values: int(str(value) in (values or '').split(','))) + session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) async with engine.begin() as connection: await connection.run_sync(Base.metadata.create_all, tables=[SysDept.__table__]) @@ -88,3 +94,98 @@ async def test_update_dept_sort_rolls_back_and_wraps_database_error() -> None: assert exc_info.value.message == '保存排序异常,请联系管理员' query_db.commit.assert_not_awaited() query_db.rollback.assert_awaited_once() + + +async def _seed_dept_tree(session: AsyncSession) -> None: + session.add_all( + [ + SysDept(dept_id=100, parent_id=0, ancestors='0', dept_name='总部门'), + SysDept(dept_id=101, parent_id=100, ancestors='0,100', dept_name='研发部门'), + SysDept(dept_id=102, parent_id=101, ancestors='0,100,101', dept_name='研发小组'), + SysDept(dept_id=103, parent_id=102, ancestors='0,100,101,102', dept_name='研发子组'), + SysDept(dept_id=200, parent_id=100, ancestors='0,100', dept_name='测试部门'), + SysDept(dept_id=201, parent_id=100, ancestors='0,100', dept_name='已删除部门', del_flag='2'), + ] + ) + await session.commit() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [101, 102, 103, 201, 999]) +async def test_edit_dept_rejects_cycles_and_invalid_parents_without_writing(parent_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + query = select(SysDept.dept_id, SysDept.parent_id, SysDept.ancestors).order_by(SysDept.dept_id) + before = (await session.execute(query)).all() + with pytest.raises(ServiceException): + await DeptService.edit_dept_services( + session, DeptModel(deptId=101, parentId=parent_id, deptName='研发部门', orderNum=1) + ) + assert (await session.execute(query)).all() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [0, 200]) +async def test_edit_dept_moves_whole_subtree_and_rebuilds_ancestors(parent_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + result = await DeptService.edit_dept_services( + session, + DeptModel(deptId=101, parentId=parent_id, deptName='研发部门', ancestors='forged', orderNum=1), + ) + ancestor_prefix = '0' if parent_id == 0 else '0,100,200' + rows = ( + await session.execute( + select(SysDept.dept_id, SysDept.parent_id, SysDept.ancestors) + .where(SysDept.dept_id.in_([101, 102, 103])) + .order_by(SysDept.dept_id) + ) + ).all() + assert result.is_success + assert rows == [ + (101, parent_id, ancestor_prefix), + (102, 101, f'{ancestor_prefix},101'), + (103, 102, f'{ancestor_prefix},101,102'), + ] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dept_id', [100, 101]) +async def test_edit_dept_preserves_omitted_parent_and_ignores_supplied_ancestors(dept_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + query = select(SysDept.parent_id, SysDept.ancestors).where(SysDept.dept_id == dept_id) + before = (await session.execute(query)).one() + await DeptService.edit_dept_services( + session, DeptModel(deptId=dept_id, deptName='新名称', ancestors='0,999', orderNum=1) + ) + assert (await session.execute(query)).one() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [None, 201, 999]) +async def test_add_dept_rejects_missing_or_deleted_parent(parent_id: int | None) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + with pytest.raises(ServiceException) as exc_info: + await DeptService.add_dept_services( + session, DeptModel(deptId=300, parentId=parent_id, deptName='新部门', orderNum=1) + ) + assert exc_info.value.message == '上级部门不存在' + assert await session.get(SysDept, 300) is None + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py index 17ad1944b..ded604c2b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py @@ -75,7 +75,7 @@ def test_file_data_scope_does_not_match_unowned_files_when_user_has_no_departmen user_id=20, dept_id=None, admin=False, - role=[SimpleNamespace(role_id=2, data_scope=data_scope)], + role=[SimpleNamespace(role_id=2, data_scope=data_scope, status='0', del_flag='0')], ) ) diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py index 2e9b6ddd6..0d6e1e062 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py @@ -3,7 +3,7 @@ import pytest from sqlalchemy import select -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from common.constant import CommonConstant from config.database import Base @@ -222,3 +222,65 @@ async def test_update_menu_sort_rolls_back_and_wraps_database_error() -> None: assert exc_info.value.message == '保存排序异常,请联系管理员' query_db.commit.assert_not_awaited() query_db.rollback.assert_awaited_once() + + +async def _create_menu_tree() -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + sessions = async_sessionmaker(bind=engine, expire_on_commit=False) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all, tables=[SysMenu.__table__]) + async with sessions() as session: + session.add_all( + [ + SysMenu(menu_id=501, parent_id=0, menu_name='系统目录', menu_type='M', path='system'), + SysMenu(menu_id=502, parent_id=501, menu_name='用户菜单', menu_type='C', path='user'), + SysMenu(menu_id=503, parent_id=502, menu_name='新增按钮', menu_type='F'), + SysMenu(menu_id=600, parent_id=0, menu_name='工具目录', menu_type='M', path='tools'), + SysMenu(menu_id=701, parent_id=702, menu_name='异常目录一', menu_type='M'), + SysMenu(menu_id=702, parent_id=701, menu_name='异常目录二', menu_type='M'), + SysMenu(menu_id=800, parent_id=999, menu_name='孤立目录', menu_type='M'), + ] + ) + await session.commit() + return engine, sessions + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [501, 502, 503, 701, 800, 999]) +async def test_edit_menu_rejects_invalid_parent_chains_without_writing(parent_id: int) -> None: + engine, sessions = await _create_menu_tree() + try: + async with sessions() as session: + query = select(SysMenu.menu_id, SysMenu.parent_id).order_by(SysMenu.menu_id) + before = (await session.execute(query)).all() + with pytest.raises(ServiceException): + await MenuService.edit_menu_services( + session, + MenuModel(menuId=501, parentId=parent_id, menuName='系统目录', menuType='M', path='system'), + ) + assert (await session.execute(query)).all() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [None, 0, 600]) +async def test_edit_menu_preserves_root_or_moves_subtree_to_valid_parent(parent_id: int | None) -> None: + engine, sessions = await _create_menu_tree() + try: + async with sessions() as session: + result = await MenuService.edit_menu_services( + session, + MenuModel(menuId=501, parentId=parent_id, menuName='新系统目录', menuType='M', path='system'), + ) + rows = ( + await session.execute( + select(SysMenu.menu_id, SysMenu.parent_id) + .where(SysMenu.menu_id.in_([501, 502, 503])) + .order_by(SysMenu.menu_id) + ) + ).all() + assert result.is_success + assert rows == [(501, parent_id or 0), (502, 501), (503, 502)] + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py new file mode 100644 index 000000000..9f8b4e339 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py @@ -0,0 +1,88 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module_admin.dao.notice_dao import NoticeDao +from module_admin.entity.do.notice_do import SysNotice +from module_admin.entity.vo.notice_vo import NoticeModel, NoticePageQueryModel +from module_admin.service.notice_service import NoticeService + +PAYLOAD = '
正文重点
重点
x