签入版本

This commit is contained in:
2026-09-12 17:39:13 +08:00
parent 25c8b039ac
commit 3233d221b0
871 changed files with 103088 additions and 10561 deletions
+72 -75
View File
@@ -1,66 +1,60 @@
[
"sys:ad:add",
"sys:ad:batch_delete",
"sys:ad:delete",
"sys:ad:detail",
"sys:ad:page",
"sys:ad:status",
"sys:ad:update",
"sys:adsort:add",
"sys:adsort:batch_delete",
"sys:adsort:delete",
"sys:adsort:detail",
"sys:adsort:page",
"sys:adsort:update",
"sys:article:add",
"sys:article:batchDelete",
"sys:article:delete",
"sys:article:detail",
"sys:article:page",
"sys:article:status",
"sys:article:update",
"sys:category:add",
"sys:category:delete",
"sys:category:detail",
"sys:category:page",
"sys:category:update",
"sys:city:add",
"sys:city:batch_delete",
"sys:city:batchDelete",
"sys:city:delete",
"sys:city:detail",
"sys:city:page",
"sys:city:list",
"sys:city:update",
"sys:config:add",
"sys:config:batch_delete",
"sys:config:batchDelete",
"sys:config:delete",
"sys:config:detail",
"sys:config:page",
"sys:config:status",
"sys:config:update",
"sys:configitem:add",
"sys:configitem:batch_delete",
"sys:configitem:delete",
"sys:configitem:detail",
"sys:configitem:page",
"sys:configitem:status",
"sys:configitem:update",
"sys:configWeb:data",
"sys:configWeb:save",
"sys:dept:add",
"sys:dept:data",
"sys:dept:delete",
"sys:dept:detail",
"sys:dept:list",
"sys:dept:update",
"sys:dict:add",
"sys:dict:batch_delete",
"sys:dict:batchDelete",
"sys:dict:delete",
"sys:dict:detail",
"sys:dict:page",
"sys:dict:update",
"sys:dictitem:add",
"sys:dictitem:batch_delete",
"sys:dictitem:delete",
"sys:dictitem:detail",
"sys:dictitem:page",
"sys:dictitem:status",
"sys:dictitem:update",
"sys:item:add",
"sys:item:batch_delete",
"sys:item:delete",
"sys:item:detail",
"sys:item:page",
"sys:item:update",
"sys:itemcate:add",
"sys:itemcate:batch_delete",
"sys:itemcate:delete",
"sys:itemcate:detail",
"sys:itemcate:page",
"sys:itemcate:update",
"sys:example:add",
"sys:example:batchDelete",
"sys:example:delete",
"sys:example:detail",
"sys:example:page",
"sys:example:status",
"sys:example:update",
"sys:fileTemplate:add",
"sys:fileTemplate:batchDelete",
"sys:fileTemplate:delete",
"sys:fileTemplate:detail",
"sys:fileTemplate:page",
"sys:fileTemplate:update",
"sys:generator:batchGenerate",
"sys:generator:detail",
"sys:generator:generate",
"sys:generator:page",
"sys:job:add",
"sys:job:delete",
"sys:job:detail",
@@ -71,80 +65,83 @@
"sys:job:run_once",
"sys:job:status",
"sys:job:update",
"sys:job_log:batch_delete",
"sys:job_log:batchDelete",
"sys:job_log:delete",
"sys:job_log:detail",
"sys:job_log:page",
"sys:level:add",
"sys:level:batch_delete",
"sys:level:batchDelete",
"sys:level:delete",
"sys:level:detail",
"sys:level:export",
"sys:level:import",
"sys:level:list",
"sys:level:page",
"sys:level:status",
"sys:level:update",
"sys:link:add",
"sys:link:batch_delete",
"sys:link:batchDelete",
"sys:link:delete",
"sys:link:detail",
"sys:link:page",
"sys:link:status",
"sys:link:update",
"sys:login_log:batch_delete",
"sys:login_log:delete",
"sys:login_log:page",
"sys:member:add",
"sys:member:batch_delete",
"sys:member:delete",
"sys:member:detail",
"sys:member:page",
"sys:member:status",
"sys:member:update",
"sys:memberlevel:add",
"sys:memberlevel:batch_delete",
"sys:memberlevel:delete",
"sys:memberlevel:detail",
"sys:memberlevel:page",
"sys:memberlevel:update",
"sys:loginLog:batchDelete",
"sys:loginLog:delete",
"sys:loginLog:detail",
"sys:loginLog:page",
"sys:menu:add",
"sys:menu:delete",
"sys:menu:detail",
"sys:menu:list",
"sys:menu:page",
"sys:menu:update",
"sys:notice:add",
"sys:notice:batch_delete",
"sys:notice:batchDelete",
"sys:notice:delete",
"sys:notice:detail",
"sys:notice:page",
"sys:notice:update",
"sys:operation_log:batch_delete",
"sys:operation_log:delete",
"sys:operation_log:page",
"sys:operationLog:batchDelete",
"sys:operationLog:delete",
"sys:operationLog:detail",
"sys:operationLog:page",
"sys:param:add",
"sys:param:batchDelete",
"sys:param:delete",
"sys:param:detail",
"sys:param:page",
"sys:param:update",
"sys:position:add",
"sys:position:batch_delete",
"sys:position:batchDelete",
"sys:position:delete",
"sys:position:detail",
"sys:position:list",
"sys:position:page",
"sys:position:status",
"sys:position:update",
"sys:profile:updatePassword",
"sys:role:add",
"sys:role:batch_delete",
"sys:role:batchDelete",
"sys:role:delete",
"sys:role:detail",
"sys:role:getPermission",
"sys:role:list",
"sys:role:page",
"sys:role:status",
"sys:role:savePermission",
"sys:role:update",
"sys:rolemenu:index",
"sys:rolemenu:save",
"sys:upload:file",
"sys:upload:image",
"sys:user:add",
"sys:user:batch_delete",
"sys:user:batchDelete",
"sys:user:delete",
"sys:user:detail",
"sys:user:export",
"sys:user:import",
"sys:user:page",
"sys:user:resetPwd",
"sys:user:status",
"sys:user:update"
"sys:user:update",
"sys:user_role:clear",
"sys:user_role:delete",
"sys:user_role:list",
"sys:user_role:set"
]
+168 -167
View File
@@ -1,169 +1,170 @@
[
"DELETE /ad/batchDelete",
"DELETE /ad/delete/{id}",
"DELETE /adsort/batchDelete",
"DELETE /adsort/delete/{id}",
"DELETE /city/batchDelete",
"DELETE /city/delete/{id}",
"DELETE /config/batchDelete",
"DELETE /config/delete/{id}",
"DELETE /configitem/batchDelete",
"DELETE /configitem/delete/{id}",
"DELETE /dept/delete/{id}",
"DELETE /dict/batchDelete",
"DELETE /dict/delete/{id}",
"DELETE /dictitem/batchDelete",
"DELETE /dictitem/delete/{id}",
"DELETE /item/batchDelete",
"DELETE /item/delete/{id}",
"DELETE /itemcate/batchDelete",
"DELETE /itemcate/delete/{id}",
"DELETE /job/delete/{id}",
"DELETE /joblog/batchDelete",
"DELETE /joblog/delete/{id}",
"DELETE /level/batchDelete",
"DELETE /level/delete/{id}",
"DELETE /link/batchDelete",
"DELETE /link/delete/{id}",
"DELETE /loginlog/batchDelete",
"DELETE /loginlog/delete/{id}",
"DELETE /member/batchDelete",
"DELETE /member/delete/{id}",
"DELETE /memberlevel/batchDelete",
"DELETE /memberlevel/delete/{id}",
"DELETE /menu/delete/{id}",
"DELETE /notice/batchDelete",
"DELETE /notice/delete/{id}",
"DELETE /operationlog/batchDelete",
"DELETE /operationlog/delete/{id}",
"DELETE /position/batchDelete",
"DELETE /position/delete/{id}",
"DELETE /role/batchDelete",
"DELETE /role/delete/{id}",
"DELETE /user/batchDelete",
"DELETE /user/delete/{id}",
"GET /ad/detail/{id}",
"GET /ad/page",
"GET /adsort/detail/{id}",
"GET /adsort/getAdSortList",
"GET /adsort/page",
"GET /captcha",
"GET /city/detail/{id}",
"GET /city/getChildList/{city_code}",
"GET /city/page",
"GET /config/detail/{id}",
"GET /config/page",
"GET /config/web",
"GET /configitem/detail/{id}",
"GET /configitem/page",
"GET /dept/detail/{id}",
"GET /dept/getDeptList",
"GET /dept/list",
"GET /dict/detail/{id}",
"GET /dict/page",
"GET /dictitem/detail/{id}",
"GET /dictitem/page",
"GET /index/menu",
"GET /index/user",
"GET /item/detail/{id}",
"GET /item/getItemList",
"GET /item/page",
"GET /itemcate/detail/{id}",
"GET /itemcate/getCateList",
"GET /itemcate/page",
"GET /job/detail/{id}",
"GET /job/page",
"GET /joblog/detail/{id}",
"GET /joblog/page",
"GET /level/detail/{id}",
"GET /level/getLevelList",
"GET /level/page",
"GET /link/detail/{id}",
"GET /link/page",
"GET /loginlog/page",
"GET /member/detail/{id}",
"GET /member/page",
"GET /memberlevel/detail/{id}",
"GET /memberlevel/getMemberLevelList",
"GET /memberlevel/page",
"GET /menu/detail/{id}",
"GET /menu/list",
"GET /notice/detail/{id}",
"GET /notice/page",
"GET /operationlog/page",
"GET /position/detail/{id}",
"GET /position/getPositionList",
"GET /position/page",
"GET /role/detail/{id}",
"GET /role/getRoleList",
"GET /role/page",
"GET /rolemenu/index/{role_id}",
"GET /user/detail/{id}",
"GET /user/page",
"POST /ad/add",
"POST /adsort/add",
"POST /city/add",
"POST /config/add",
"POST /configitem/add",
"POST /dept/add",
"POST /dict/add",
"POST /dictitem/add",
"POST /index/logout",
"POST /item/add",
"POST /itemcate/add",
"POST /job/add",
"POST /job/reload",
"POST /job/runOnce/{id}",
"POST /level/add",
"POST /level/export",
"POST /level/import",
"POST /link/add",
"POST /login",
"POST /member/add",
"POST /memberlevel/add",
"POST /menu/add",
"POST /notice/add",
"POST /position/add",
"POST /role/add",
"POST /rolemenu/save",
"POST /upload/uploadEditImage",
"POST /upload/uploadFile",
"POST /upload/uploadImage",
"POST /user/add",
"POST /user/resetPwd",
"PUT /ad/status",
"PUT /ad/update",
"PUT /adsort/update",
"PUT /city/update",
"PUT /config/update",
"PUT /config/webSave",
"PUT /configitem/status",
"PUT /configitem/update",
"PUT /dept/update",
"PUT /dict/update",
"PUT /dictitem/status",
"PUT /dictitem/update",
"PUT /index/updatePwd",
"PUT /index/userInfo",
"PUT /item/update",
"PUT /itemcate/update",
"PUT /job/pause/{id}",
"PUT /job/resume/{id}",
"PUT /job/status",
"PUT /job/update",
"PUT /level/status",
"PUT /level/update",
"PUT /link/status",
"PUT /link/update",
"PUT /member/status",
"PUT /member/update",
"PUT /memberlevel/update",
"PUT /menu/update",
"PUT /notice/update",
"PUT /position/status",
"PUT /position/update",
"PUT /role/status",
"PUT /role/update",
"PUT /user/status",
"PUT /user/update"
"DELETE /api/v1/article/batchDelete",
"DELETE /api/v1/article/delete/{id}",
"DELETE /api/v1/category/delete/{id}",
"DELETE /api/v1/city/batchDelete",
"DELETE /api/v1/city/delete/{id}",
"DELETE /api/v1/config/batchDelete",
"DELETE /api/v1/config/delete/{id}",
"DELETE /api/v1/config/item/batchDelete",
"DELETE /api/v1/config/item/delete/{id}",
"DELETE /api/v1/dept/delete/{id}",
"DELETE /api/v1/dict/batchDelete",
"DELETE /api/v1/dict/delete/{id}",
"DELETE /api/v1/dict/item/batchDelete",
"DELETE /api/v1/dict/item/delete/{id}",
"DELETE /api/v1/example/batchDelete",
"DELETE /api/v1/example/delete/{id}",
"DELETE /api/v1/file/template/batchDelete",
"DELETE /api/v1/file/template/delete/{id}",
"DELETE /api/v1/job/delete/{id}",
"DELETE /api/v1/job/log/batchDelete",
"DELETE /api/v1/job/log/delete/{id}",
"DELETE /api/v1/level/batchDelete",
"DELETE /api/v1/level/delete/{id}",
"DELETE /api/v1/link/batchDelete",
"DELETE /api/v1/link/delete/{id}",
"DELETE /api/v1/login/log/batchDelete",
"DELETE /api/v1/login/log/delete/{id}",
"DELETE /api/v1/menu/delete/{id}",
"DELETE /api/v1/notice/batchDelete",
"DELETE /api/v1/notice/delete/{id}",
"DELETE /api/v1/oper/log/batchDelete",
"DELETE /api/v1/oper/log/delete/{id}",
"DELETE /api/v1/param/batchDelete",
"DELETE /api/v1/param/delete/{id}",
"DELETE /api/v1/position/batchDelete",
"DELETE /api/v1/position/delete/{id}",
"DELETE /api/v1/role/batchDelete",
"DELETE /api/v1/role/delete/{id}",
"DELETE /api/v1/user/batchDelete",
"DELETE /api/v1/user/delete/{id}",
"DELETE /api/v1/user/role/clear/{userId}",
"DELETE /api/v1/user/role/delete/{userId}/{roleId}",
"GET /api/v1/article/detail/{id}",
"GET /api/v1/article/page",
"GET /api/v1/captcha",
"GET /api/v1/category/detail/{id}",
"GET /api/v1/category/getCategoryData",
"GET /api/v1/category/list",
"GET /api/v1/city/detail/{id}",
"GET /api/v1/city/getCityList/{city_code}",
"GET /api/v1/city/list",
"GET /api/v1/config/detail/{id}",
"GET /api/v1/config/item/detail/{id}",
"GET /api/v1/config/item/page",
"GET /api/v1/config/page",
"GET /api/v1/config/web/index",
"GET /api/v1/dept/detail/{id}",
"GET /api/v1/dept/getDeptList",
"GET /api/v1/dept/list",
"GET /api/v1/dict/data/{code}",
"GET /api/v1/dict/detail/{id}",
"GET /api/v1/dict/item/detail/{id}",
"GET /api/v1/dict/item/page",
"GET /api/v1/dict/page",
"GET /api/v1/example/detail/{id}",
"GET /api/v1/example/page",
"GET /api/v1/file/template/detail/{id}",
"GET /api/v1/file/template/getTemplateByCode/{code}",
"GET /api/v1/file/template/page",
"GET /api/v1/generator/columns/{table_name}",
"GET /api/v1/generator/detail/{table_name}",
"GET /api/v1/generator/page",
"GET /api/v1/index/getMenus",
"GET /api/v1/index/getUser",
"GET /api/v1/job/detail/{id}",
"GET /api/v1/job/log/detail/{id}",
"GET /api/v1/job/log/page",
"GET /api/v1/job/page",
"GET /api/v1/level/detail/{id}",
"GET /api/v1/level/export",
"GET /api/v1/level/list",
"GET /api/v1/level/page",
"GET /api/v1/link/detail/{id}",
"GET /api/v1/link/page",
"GET /api/v1/login/log/detail/{id}",
"GET /api/v1/login/log/page",
"GET /api/v1/logout",
"GET /api/v1/menu/detail/{id}",
"GET /api/v1/menu/list",
"GET /api/v1/notice/detail/{id}",
"GET /api/v1/notice/page",
"GET /api/v1/oper/log/detail/{id}",
"GET /api/v1/oper/log/page",
"GET /api/v1/param/detail/{id}",
"GET /api/v1/param/page",
"GET /api/v1/position/detail/{id}",
"GET /api/v1/position/list",
"GET /api/v1/position/page",
"GET /api/v1/role/detail/{id}",
"GET /api/v1/role/list",
"GET /api/v1/role/menu/list/{role_id}",
"GET /api/v1/role/page",
"GET /api/v1/user/detail/{id}",
"GET /api/v1/user/export",
"GET /api/v1/user/page",
"GET /api/v1/user/role/list/{userId}",
"POST /api/v1/article/add",
"POST /api/v1/category/add",
"POST /api/v1/city/add",
"POST /api/v1/config/add",
"POST /api/v1/config/item/add",
"POST /api/v1/config/web/save",
"POST /api/v1/dept/add",
"POST /api/v1/dict/add",
"POST /api/v1/dict/item/add",
"POST /api/v1/example/add",
"POST /api/v1/file/template/add",
"POST /api/v1/generator/batchGenerate",
"POST /api/v1/generator/generate",
"POST /api/v1/job/add",
"POST /api/v1/job/reload",
"POST /api/v1/job/runOnce/{id}",
"POST /api/v1/job/status",
"POST /api/v1/level/add",
"POST /api/v1/level/import",
"POST /api/v1/link/add",
"POST /api/v1/login",
"POST /api/v1/menu/add",
"POST /api/v1/notice/add",
"POST /api/v1/param/add",
"POST /api/v1/position/add",
"POST /api/v1/role/add",
"POST /api/v1/role/menu/save",
"POST /api/v1/upload/uploadFile",
"POST /api/v1/user/add",
"POST /api/v1/user/import",
"PUT /api/v1/article/status",
"PUT /api/v1/article/update",
"PUT /api/v1/category/update",
"PUT /api/v1/city/update",
"PUT /api/v1/config/item/status",
"PUT /api/v1/config/item/update",
"PUT /api/v1/config/update",
"PUT /api/v1/dept/update",
"PUT /api/v1/dict/item/update",
"PUT /api/v1/dict/update",
"PUT /api/v1/example/status",
"PUT /api/v1/example/update",
"PUT /api/v1/file/template/update",
"PUT /api/v1/index/updatePassword",
"PUT /api/v1/index/updateProfile",
"PUT /api/v1/job/pause/{id}",
"PUT /api/v1/job/resume/{id}",
"PUT /api/v1/job/update",
"PUT /api/v1/level/status",
"PUT /api/v1/level/update",
"PUT /api/v1/link/status",
"PUT /api/v1/link/update",
"PUT /api/v1/menu/update",
"PUT /api/v1/notice/update",
"PUT /api/v1/param/update",
"PUT /api/v1/position/status",
"PUT /api/v1/position/update",
"PUT /api/v1/role/update",
"PUT /api/v1/user/resetPwd",
"PUT /api/v1/user/role/set",
"PUT /api/v1/user/status",
"PUT /api/v1/user/update"
]
+6 -8
View File
@@ -1,24 +1,22 @@
[
"fastapi_ad",
"fastapi_ad_sort",
"fastapi_article",
"fastapi_category",
"fastapi_city",
"fastapi_config",
"fastapi_config_data",
"fastapi_config_item",
"fastapi_dept",
"fastapi_dict",
"fastapi_dict_data",
"fastapi_item",
"fastapi_item_cate",
"fastapi_dict_item",
"fastapi_file_template",
"fastapi_job",
"fastapi_job_log",
"fastapi_level",
"fastapi_link",
"fastapi_login_log",
"fastapi_member",
"fastapi_member_level",
"fastapi_menu",
"fastapi_notice",
"fastapi_operation_log",
"fastapi_param",
"fastapi_position",
"fastapi_role",
"fastapi_role_menu",
+120 -1
View File
@@ -12,6 +12,8 @@ import json
import os
import re
from sqlalchemy import BigInteger, DateTime, Date, Float, Integer, String, Text
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
@@ -35,7 +37,7 @@ def collect_routes():
def collect_permissions():
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
perms = set()
for root in ("apps", "modules"):
for root in ("src/api/v1/endpoints", "src/modules"):
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
with open(path, encoding="utf-8") as f:
perms.update(PERMISSION_RE.findall(f.read()))
@@ -64,3 +66,120 @@ def compare_snapshot(name: str, actual: list):
f"新增: {sorted(set(actual) - set(golden))}\n"
f"缺失: {sorted(set(golden) - set(actual))}"
)
# ============================================================
# 模型 ↔ SQL dump 建表结构一致性
# ============================================================
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SQL_SCHEMA_PATH = os.path.join(PROJECT_ROOT, "document", "djangoadmin.fastapi.elevue.sql")
def parse_sql_type(type_tail: str):
"""从列定义中解析 SQL 类型,归一化到与模型一致的 (类型族, 长度)。
SQL 方言与归一化规则:
int/int(0)/tinyint(1)/smallint/mediumint/bit(1) -> "int"
bigint -> "bigint"
datetime(0)/datetime(6)/timestamp -> "datetime"
date -> "date"
varchar(n)/char(n) -> ("varchar", n)
text/tinytext/mediumtext/longtext -> "text"
float/double/decimal -> "float"
"""
m = re.match(r"([a-z]+)(?:\((\d+)\))?", type_tail.strip().lower())
if not m:
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
name, length = m.group(1), m.group(2)
if name in ("int", "tinyint", "smallint", "mediumint", "bit"):
return ("int", None)
if name == "bigint":
return ("bigint", None)
if name in ("datetime", "timestamp"):
return ("datetime", None)
if name == "date":
return ("date", None)
if name in ("varchar", "char"):
return ("varchar", int(length))
if name in ("text", "tinytext", "mediumtext", "longtext"):
return ("text", None)
if name in ("float", "double", "decimal"):
return ("float", None)
raise ValueError(f"无法解析 SQL 类型: {type_tail!r}")
def collect_sql_schema():
"""解析 SQL dump 中的 CREATE TABLE,产出 {表名: {列名: (类型族, 长度, 可空)}} 与 {表名: 主键集}。
兼容 Navicat 导出(Style A:`` `col` int(0) NULL DEFAULT NULL ``)与
手写(Style B:`` `col` int NOT NULL ``)两种格式。
"""
with open(SQL_SCHEMA_PATH, encoding="utf-8") as f:
content = f.read()
schema, pk_map = {}, {}
for m in re.finditer(r"CREATE TABLE\s+`([^`]+)`\s*\((.*?)\)\s*ENGINE", content, re.S):
table = m.group(1)
columns, pks = {}, set()
for line in m.group(2).splitlines():
s = line.strip().rstrip(",")
if not s or s.startswith("PRIMARY KEY"):
if s.startswith("PRIMARY KEY"):
pk_m = re.search(r"`([^`]+)`", s)
if pk_m:
pks.add(pk_m.group(1))
continue
if s.startswith(("KEY ", "UNIQUE KEY", "INDEX ", "FULLTEXT ")):
continue
col_m = re.match(r"`([^`]+)`\s+(.+)$", s)
if not col_m:
continue
col, type_tail = col_m.group(1), col_m.group(2)
# 忽略 COMMENT 子句后再判断可空,避免注释文本误含 NOT NULL
pre = type_tail.split("COMMENT")[0]
nullable = "NOT NULL" not in pre.upper()
columns[col] = (*parse_sql_type(type_tail), nullable)
schema[table] = columns
pk_map[table] = pks
return schema, pk_map
def model_type(col):
"""将 SQLAlchemy 列类型映射到与 parse_sql_type 相同的 (类型族, 长度)。"""
t = col.type
if isinstance(t, BigInteger):
return ("bigint", None)
if isinstance(t, Integer):
return ("int", None)
if isinstance(t, DateTime):
return ("datetime", None)
if isinstance(t, Date):
return ("date", None)
# 注意:Text 是 String 的子类,必须先判断 Text
if isinstance(t, Text):
return ("text", None)
if isinstance(t, String):
return ("varchar", t.length)
if isinstance(t, Float):
return ("float", None)
raise ValueError(f"无法映射模型列类型: {col} ({t!r})")
def collect_model_schema():
"""通过 create_app 强制加载全部模型,从 Base.metadata 产出与 collect_sql_schema 同构的数据。"""
from core.app import create_app
create_app()
from core.database import Base
schema, pk_map = {}, {}
for table_name, table in Base.metadata.tables.items():
columns, pks = {}, set()
for col in table.columns:
columns[col.name] = (*model_type(col), col.nullable)
if col.primary_key:
pks.add(col.name)
schema[table_name] = columns
pk_map[table_name] = pks
return schema, pk_map
@@ -0,0 +1,46 @@
"""模型 ↔ SQL dump 建表结构一致性契约测试。
document/djangoadmin.fastapi.elevue.sql 是建表事实来源
(scripts/init_db.py 以 SQL 为准、模型仅 backfill),因此 SQLAlchemy 模型
metadata 必须与 SQL 建表语句保持一致,否则 ORM 读写与建表脚本会产生隐蔽漂移。
对比维度:表集合、列名集合、每列 (类型族, 长度, 可空)、每表主键集合。
忽略:default/server_default、字符集、排序规则、显示宽度、索引、自增起始值。
"""
import pytest
from tests.contract.helpers import collect_model_schema, collect_sql_schema
pytestmark = pytest.mark.contract
def test_table_sets_match():
sql_schema, _ = collect_sql_schema()
model_schema, _ = collect_model_schema()
assert set(sql_schema) == set(model_schema), (
f"表集合不一致!\n"
f"仅SQL存在: {sorted(set(sql_schema) - set(model_schema))}\n"
f"仅模型存在: {sorted(set(model_schema) - set(sql_schema))}"
)
def test_columns_and_types_match():
sql_schema, sql_pk = collect_sql_schema()
model_schema, model_pk = collect_model_schema()
assert set(sql_schema) == set(model_schema), "表集合不一致,先修复表集合再对比列"
for table in sorted(sql_schema):
sql_cols = set(sql_schema[table])
model_cols = set(model_schema[table])
assert sql_cols == model_cols, (
f"[{table}] 列名不一致!\n"
f"仅SQL存在: {sorted(sql_cols - model_cols)}\n"
f"仅模型存在: {sorted(model_cols - sql_cols)}"
)
for col in sorted(sql_cols):
assert sql_schema[table][col] == model_schema[table][col], (
f"[{table}.{col}] 定义不一致: SQL={sql_schema[table][col]} "
f"模型={model_schema[table][col]}"
)
assert sql_pk[table] == model_pk[table], (
f"[{table}] 主键不一致: SQL={sorted(sql_pk[table])} 模型={sorted(model_pk[table])}"
)
+14 -9
View File
@@ -7,24 +7,28 @@ pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_login_flow(client):
"""完整登录链路:/captcha → 解析验证码 → /login 获取 token。"""
"""完整登录链路:/api/v1/captcha → 解析验证码 → /api/v1/login 获取 token。"""
import redis as redis_lib
from config import REDIS_INDEX
from core.config import REDIS_AUTH, REDIS_HOST, REDIS_INDEX, REDIS_PASSWORD, REDIS_PORT
r = await client.get("/captcha")
r = await client.get("/api/v1/captcha")
body = r.json()
assert body["code"] == 0
idkey = body.get("idkey")
assert idkey
# 从 Redis 读取验证码明文
redis_client = redis_lib.Redis(db=REDIS_INDEX)
# 从 Redis 读取验证码明文(带认证参数,与 app 连接一致)
redis_client = redis_lib.Redis(
host=REDIS_HOST, port=REDIS_PORT,
password=REDIS_PASSWORD if REDIS_AUTH else None,
db=REDIS_INDEX,
)
code = redis_client.get(idkey).decode()
resp = await client.post("/login", json={
resp = await client.post("/api/v1/login", json={
"username": "admin", "password": "123456",
"captcha": code, "idKey": idkey,
"code": code, "key": idkey,
})
login = resp.json()
assert login["code"] == 0
@@ -34,7 +38,8 @@ async def test_login_flow(client):
@pytest.mark.asyncio
async def test_admin_read_endpoints(client, admin_headers):
"""管理员 token 可访问代表性只读接口。"""
for path in ("/user/page", "/dept/list", "/menu/list", "/index/menu", "/job/page"):
for path in ("/api/v1/user/page", "/api/v1/dept/list", "/api/v1/menu/list",
"/api/v1/index/menu", "/api/v1/job/page"):
resp = await client.get(path, headers=admin_headers)
assert resp.json()["code"] == 0, f"{path} failed"
@@ -42,5 +47,5 @@ async def test_admin_read_endpoints(client, admin_headers):
@pytest.mark.asyncio
async def test_unauthorized_blocked(client):
"""无 token 访问受保护接口应返回 401。"""
resp = await client.get("/user/page")
resp = await client.get("/api/v1/user/page")
assert resp.json()["code"] == 401
+37 -34
View File
@@ -1,77 +1,80 @@
"""事务与操作日志集成测试:db_session_middleware 的 commit/rollback 分支、operation_log 独立落库。
需真实 MySQL/Redis 运行(默认标记 integration 跳过)。
需真实 MySQL/PostgreSQL/SQL Server/SQLite + Redis 运行(默认标记 integration 跳过)。
"""
import pytest
import pymysql
from sqlalchemy import text
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
from core.config import DB_PREFIX
from core.database import engine
pytestmark = pytest.mark.integration
def _query_count(table, where, *args):
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
password=DB_PASSWORD, database=DB_DATABASE)
cur = conn.cursor()
cur.execute(f"SELECT COUNT(*) FROM `{table}` WHERE {where}", args)
n = cur.fetchone()[0]
conn.close()
return n
def _params(*values):
"""位置参数 → 命名参数(p1/p2/...),无参数返回 None。"""
return {f"p{i + 1}": v for i, v in enumerate(values)} if values else None
def _execute(sql, *args):
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
password=DB_PASSWORD, database=DB_DATABASE)
cur = conn.cursor()
cur.execute(sql, args)
conn.commit()
conn.close()
def _query_count(table, where, *values):
with engine.connect() as conn:
return conn.execute(
text(f"SELECT COUNT(*) FROM {table} WHERE {where}"), _params(*values)
).scalar()
_VALID_DEPT = {"name": "测试部门T", "code": "TESTT", "type": 1, "pid": 0, "sort": 99, "status": 1}
def _execute(sql, *values):
with engine.begin() as conn:
conn.execute(text(sql), _params(*values))
def _table(name):
return DB_PREFIX + name
_VALID_DEPT = {"name": "测试部门T", "code": "TESTT", "type": 1, "parentId": 0, "sort": 99, "status": 1}
@pytest.mark.asyncio
async def test_write_commit_persists(client, admin_headers):
"""业务成功的写操作应被 db_session_middleware 提交。"""
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
assert r.json()["code"] == 0
assert _query_count("fastapi_dept", "name=%s", "测试部门T") >= 1
assert _query_count(_table("dept"), "name=:p1", "测试部门T") >= 1
# 清理测试数据
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
@pytest.mark.asyncio
async def test_write_rollback_on_validation_error(client, admin_headers):
"""参数校验失败(code!=0)时请求事务应回滚,不产生脏数据。"""
before = _query_count("fastapi_dept", "name=%s", "测试部门X")
r = await client.post("/dept/add", headers=admin_headers, json={"name": "测试部门X"}) # 缺必填字段
before = _query_count(_table("dept"), "name=:p1", "测试部门X")
r = await client.post("/api/v1/dept/add", headers=admin_headers, json={"name": "测试部门X"}) # 缺必填字段
assert r.json()["code"] != 0
assert _query_count("fastapi_dept", "name=%s", "测试部门X") == before
assert _query_count(_table("dept"), "name=:p1", "测试部门X") == before
@pytest.mark.asyncio
async def test_operation_log_persists_on_success(client, admin_headers):
"""成功的写操作后,operation_log 应通过独立会话落库(标题正确)。"""
before = _query_count("fastapi_operation_log", "1=1")
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
before = _query_count(_table("operation_log"), "1=1")
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
assert r.json()["code"] == 0
after = _query_count("fastapi_operation_log", "1=1")
after = _query_count(_table("operation_log"), "1=1")
assert after >= before + 1, "operation_log 未新增记录"
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
@pytest.mark.asyncio
async def test_operation_log_persists_on_failure(client, admin_headers):
"""业务失败的写操作也应记录 operation_log(status=1),且独立会话不受请求回滚影响。"""
# 构造一个通过参数校验但业务失败的操作:部门编码重复(唯一性校验)
await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
before = _query_count("fastapi_operation_log", "1=1")
await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
before = _query_count(_table("operation_log"), "1=1")
# 再次添加同名同编码 → 业务失败
r = await client.post("/dept/add", headers=admin_headers, json=_VALID_DEPT)
r = await client.post("/api/v1/dept/add", headers=admin_headers, json=_VALID_DEPT)
assert r.json()["code"] != 0
after = _query_count("fastapi_operation_log", "1=1")
after = _query_count(_table("operation_log"), "1=1")
assert after >= before + 1, "业务失败的写操作未记录 operation_log"
_execute("DELETE FROM fastapi_dept WHERE name=%s", "测试部门T")
_execute(f"DELETE FROM {_table('dept')} WHERE name=:p1", "测试部门T")
+742
View File
@@ -0,0 +1,742 @@
# -*- coding: utf-8 -*-
"""BUG-5 ~ BUG-9 及 H-1 ~ H-4 修复验证测试。"""
import asyncio
import json
import sys
import pytest
sys.path.insert(0, 'src')
# ============================================================
# BUG-5: 权限装饰器走缓存(异步 + 同步端点)
# ============================================================
def _make_perm_app(monkeypatch, user_id, perm_calls, denied=False):
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from core.access_decorators import permission_required
async def fake_get_permissions_list(uid):
perm_calls.append(uid)
return [] if denied else ['sys:x:list']
import modules.system.menu.service as menu_service
monkeypatch.setattr(menu_service, 'get_permissions_list', fake_get_permissions_list)
sync_db_calls = []
monkeypatch.setattr(
menu_service, 'get_permissions_list_sync',
lambda uid: sync_db_calls.append(uid) or ([] if denied else ['sys:x:list']))
app = FastAPI()
@app.middleware("http")
async def inject_user(request: Request, call_next):
request.state.user_id = user_id
return await call_next(request)
@app.get("/sync")
@permission_required("sys:x:list")
def sync_ep(request: Request):
return {"code": 0, "ok": True}
@app.get("/async")
@permission_required("sys:x:list")
async def async_ep(request: Request):
return {"code": 0, "ok": True}
return app, sync_db_calls
def test_perm_decorator_sync_endpoint_uses_sync_check(monkeypatch):
from fastapi.testclient import TestClient
calls = []
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls)
with TestClient(app) as client:
resp = client.get("/sync")
assert resp.json()["ok"] is True
# 同步端点直接走同步 DB 校验(不再每请求新建事件循环 / 依赖 Redis 缓存)
assert calls == []
assert sync_db_calls == [2]
def test_perm_decorator_async_endpoint_uses_cache(monkeypatch):
from fastapi.testclient import TestClient
calls = []
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=2, perm_calls=calls)
with TestClient(app) as client:
resp = client.get("/async")
assert resp.json()["ok"] is True
assert calls == [2]
assert sync_db_calls == []
def test_perm_decorator_denied(monkeypatch):
from fastapi.testclient import TestClient
calls = []
app, sync_db_calls = _make_perm_app(
monkeypatch, user_id=2, perm_calls=calls, denied=True)
with TestClient(app) as client:
resp = client.get("/async")
assert resp.json()["code"] == 1
assert resp.json()["ok"] is False
def test_perm_decorator_admin_bypass(monkeypatch):
from fastapi.testclient import TestClient
calls = []
app, sync_db_calls = _make_perm_app(monkeypatch, user_id=1, perm_calls=calls)
with TestClient(app) as client:
resp = client.get("/async")
assert resp.json()["ok"] is True
assert calls == [] # 管理员不查权限
# ============================================================
# BUG-6: CORS 为最外层
# ============================================================
def test_cors_is_outermost():
from core.app import create_app
from starlette.middleware.cors import CORSMiddleware
app = create_app()
classes = [m.cls for m in app.user_middleware]
assert classes[0] is CORSMiddleware
# ============================================================
# BUG-7: 时间字段序列化统一(naive/aware UTC 均转本地)
# ============================================================
def test_to_dict_naive_utc_matches_aware_utc():
from datetime import datetime, timezone
from modules.system.menu.models import Menu
naive_obj = Menu()
naive_obj.create_time = datetime(2026, 1, 1, 8, 0, 0) # naive,表示 UTC 08:00
aware_obj = Menu()
aware_obj.create_time = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
d1 = naive_obj.to_dict()
d2 = aware_obj.to_dict()
# 两者应一致(本地时间),证明读回 naive 不再显示 UTC 原始值
assert d1['create_time'] == d2['create_time']
expected = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc) \
.astimezone().strftime('%Y-%m-%d %H:%M:%S')
assert d1['create_time'] == expected
# ============================================================
# BUG-8: 导出 URL 反斜杠转 / 分隔
# ============================================================
def test_export_level_url_uses_forward_slashes(monkeypatch, tmp_path):
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.testclient import TestClient
import modules.system.level.service as level_svc
import time
up = tmp_path / 'uploads'
temp = up / 'temp'
monkeypatch.setattr(level_svc, 'FASTAPI_UPLOAD_DIR', str(up))
monkeypatch.setattr(level_svc, 'FASTAPI_TEMP_PATH', str(temp))
monkeypatch.setattr(level_svc, 'FASTAPI_FILE_URL', 'http://img.test')
monkeypatch.setattr(level_svc, 'get_db_session', lambda: None)
class FakeQuery:
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
return self
def limit(self, *a, **k):
return self
def yield_per(self, *a, **k):
return iter(()) # 无数据,跳过导出与 expunge
class FakeRepo:
def filter_by(self):
return FakeQuery()
monkeypatch.setattr(level_svc.level_service, 'repo', FakeRepo())
app = FastAPI()
@app.middleware("http")
async def inject(request: Request, call_next):
request.state.user_id = 1
return await call_next(request)
@app.get('/export')
def export(request: Request):
return level_svc.level_service.export_level(request)
with TestClient(app) as client:
resp = client.get('/export')
url = resp.json()['data']
assert url.startswith('http://img.test/')
assert '\\' not in url
assert '/temp/' in url
assert url.endswith('.xlsx')
# ============================================================
# BUG-9: UserForm.city/roles 由 Any 收紧为带类型校验
# ============================================================
def _valid_user_form():
from datetime import date
return dict(
realname='张三', nickname='张三', gender=1, avatar='a.png',
mobile='13800000000', email='a@b.com', birthday=date(2000, 1, 1),
deptId=1, levelId=1, positionId=1,
city=['110000', '110100', '110101'], address='北京市',
username='zhangsan', password='123456', status=1, sort=1, roles=[1, 2],
)
def test_user_form_city_rejects_string_and_oversize():
from pydantic import ValidationError
from modules.system.user.schemas import UserForm
base = _valid_user_form()
# 合法数据应通过
UserForm(**base)
# city 传字符串应拒绝
with pytest.raises(ValidationError):
UserForm(**{**base, 'city': '北京市'})
# city 元素非字符串(Pydantic v2 不隐式转 str)应拒绝
with pytest.raises(ValidationError):
UserForm(**{**base, 'city': [1, 2, 3]})
# city 超过 3 个元素应拒绝
with pytest.raises(ValidationError):
UserForm(**{**base, 'city': ['1', '2', '3', '4']})
def test_user_form_roles_rejects_string():
from pydantic import ValidationError
from modules.system.user.schemas import UserForm
base = _valid_user_form()
# roles 传字符串应拒绝
with pytest.raises(ValidationError):
UserForm(**{**base, 'roles': '1,2'})
# roles 合法整型列表应通过
form = UserForm(**{**base, 'roles': [1, 2]})
assert form.roles == [1, 2]
# ============================================================
# H-1: add_user 排除 id(防批量赋值伪造)
# ============================================================
def _make_request(user_id=1):
import types
req = types.SimpleNamespace()
req.state = types.SimpleNamespace(user_id=user_id)
return req
def test_add_user_rejects_forged_id(sqlite_db, monkeypatch):
import asyncio
from modules.system.user import service as user_svc
from modules.system.user.schemas import UserForm
from modules.system.user.repository import user_repo
# 跳过头像文件落盘,直接返回原值
monkeypatch.setattr(user_svc, 'save_file', lambda url, _dir: url)
# 客户端伪造 id=999,应被排除,不写入数据库
form = UserForm(**{**_valid_user_form(), 'id': 999})
asyncio.run(user_svc.add_user(_make_request(), form))
user = user_repo.get_one(username='zhangsan')
assert user is not None
assert user.id != 999
assert user_repo.get_by_id(999) is None
# ============================================================
# H-2: login_required 直接查库校验用户状态
# ============================================================
def _make_login_app():
from fastapi import Depends, FastAPI
from starlette.requests import Request
from starlette.responses import JSONResponse
import api.deps as deps
from core.exceptions import AuthorizationException
app = FastAPI()
@app.exception_handler(AuthorizationException)
async def _auth_exc(request: Request, exc: AuthorizationException):
return JSONResponse(
status_code=200,
content={"code": exc.code, "data": None, "msg": exc.msg, "ok": False},
)
@app.get('/protected')
async def protected(request: Request, _=Depends(deps.login_required)):
return {"code": 0, "ok": True, "user_id": request.state.user_id}
return app
def test_login_required_rejects_disabled_and_deleted_user(monkeypatch):
import types
from fastapi.testclient import TestClient
import api.deps as deps
import modules.system.user.repository as user_repo_mod
async def fake_blacklist(_token):
return False
monkeypatch.setattr(deps, 'is_token_blacklisted', fake_blacklist)
def _payload(user_id):
return {'code': 0, 'data': {'userId': user_id, 'username': 'u', 'realname': 'r'}}
class FakeRepo:
def __init__(self, users):
self.users = users
def get_by_id(self, uid):
return self.users.get(uid)
# 正常用户(status=1)放行
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(2))
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({2: types.SimpleNamespace(status=1)}))
with TestClient(_make_login_app()) as client:
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
assert resp.json()['ok'] is True
assert resp.json()['user_id'] == 2
# 禁用用户(status=2)拒绝
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(3))
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({3: types.SimpleNamespace(status=2)}))
with TestClient(_make_login_app()) as client:
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
assert resp.json()['ok'] is False
assert resp.json()['code'] == 401
# 已删除/不存在用户(查无此人)拒绝
monkeypatch.setattr(deps, 'parse_payload', lambda _t: _payload(4))
monkeypatch.setattr(user_repo_mod, 'user_repo', FakeRepo({}))
with TestClient(_make_login_app()) as client:
resp = client.get('/protected', headers={'Authorization': 'Bearer x'})
assert resp.json()['ok'] is False
assert resp.json()['code'] == 401
# ============================================================
# H-2b: login_required 用户有效状态缓存(命中免查库,失效即时踢出)
# ============================================================
def _login_request():
from starlette.requests import Request
scope = {
'type': 'http', 'method': 'GET', 'path': '/protected',
'headers': [(b'authorization', b'Bearer x')],
'query_string': b'', 'route': None,
}
return Request(scope)
@pytest.mark.asyncio
async def test_login_required_cache_hit_skips_db(monkeypatch):
import fakeredis.aioredis
import api.deps as deps
import modules.system.user.repository as user_repo_mod
from utils import perm_cache
async def _blacklist(_t):
return False
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
monkeypatch.setattr(deps, 'parse_payload',
lambda _t: {'code': 0, 'data': {'userId': 2, 'username': 'u', 'realname': 'r'}})
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
await fake_redis.set('user:active:2', '1')
# 缓存命中:不应查询数据库
class _NoDb:
def get_by_id(self, uid):
raise AssertionError('缓存命中时不应查询数据库')
monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb())
request = _login_request()
await deps.login_required(request)
assert request.state.user_id == 2
@pytest.mark.asyncio
async def test_login_required_cache_inactive_rejects_without_db(monkeypatch):
import fakeredis.aioredis
import api.deps as deps
import modules.system.user.repository as user_repo_mod
from utils import perm_cache
from core.exceptions import AuthorizationException
async def _blacklist(_t):
return False
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
monkeypatch.setattr(deps, 'parse_payload',
lambda _t: {'code': 0, 'data': {'userId': 3, 'username': 'u', 'realname': 'r'}})
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
await fake_redis.set('user:active:3', '0') # 缓存已标记禁用
class _NoDb:
def get_by_id(self, uid):
raise AssertionError('缓存命中(禁用)时不应查询数据库')
monkeypatch.setattr(user_repo_mod, 'user_repo', _NoDb())
with pytest.raises(AuthorizationException):
await deps.login_required(_login_request())
@pytest.mark.asyncio
async def test_login_required_cache_miss_queries_db_and_caches(monkeypatch):
import types
import fakeredis.aioredis
import api.deps as deps
import modules.system.user.repository as user_repo_mod
from utils import perm_cache
async def _blacklist(_t):
return False
monkeypatch.setattr(deps, 'is_token_blacklisted', _blacklist)
monkeypatch.setattr(deps, 'parse_payload',
lambda _t: {'code': 0, 'data': {'userId': 5, 'username': 'u', 'realname': 'r'}})
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
class _Repo:
def get_by_id(self, uid):
return types.SimpleNamespace(status=1)
monkeypatch.setattr(user_repo_mod, 'user_repo', _Repo())
request = _login_request()
await deps.login_required(request)
assert request.state.user_id == 5
# 回源 DB 后回填缓存,供后续请求命中
assert await fake_redis.get('user:active:5') == '1'
# ============================================================
# H-3: 上传流式读取,超限立即中断(内存 DoS 防护)
# ============================================================
class _ChunkedFile:
"""模拟异步分块读取的 UploadFile(每次 read 返回一块)。"""
def __init__(self, chunks):
self.chunks = list(chunks)
async def read(self, size=65536):
if not self.chunks:
return b''
return self.chunks.pop(0)
@pytest.mark.asyncio
async def test_read_upload_limited_aborts_before_reading_all():
from modules.upload.service import _read_upload_limited, _max_upload_bytes
limit = _max_upload_bytes() # 例如 10MB
chunk = b'x' * 65536 # 64KB
n_chunks = limit // 65536 + 5 # 总量必然超限
file = _ChunkedFile([chunk] * n_chunks)
content, err = await _read_upload_limited(file, limit)
assert content is None
body = json.loads(err.body)
assert body['code'] == 1
assert '超过限制' in body['msg']
# 未读完全部块即中断,证明不是先整文件读入内存
assert len(file.chunks) > 0
@pytest.mark.asyncio
async def test_read_upload_limited_ok_under_limit():
from modules.upload.service import _read_upload_limited, _max_upload_bytes
payload = b'hello' * 100
content, err = await _read_upload_limited(_ChunkedFile([payload]), _max_upload_bytes())
assert err is None
assert content == payload
# ============================================================
# H-4: set_permission 生成的权限节点默认启用(status=0)
# ============================================================
def test_set_permission_creates_enabled_nodes(sqlite_db):
from modules.system.menu.service import set_permission
from modules.system.menu.repository import menu_repo
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
nodes = menu_repo.get_all(parent_id=1, type=1)
assert len(nodes) == 2
perms = {node.permission for node in nodes}
assert perms == {'sys:user:page', 'sys:user:add'}
for node in nodes:
assert node.status == 0
# ============================================================
# H-5: set_permission 编辑菜单时复用旧按钮节点 id,role_menu 授权不失效
# ============================================================
def test_set_permission_preserves_node_ids_on_reedit(sqlite_db):
from modules.system.menu.service import set_permission
from modules.system.menu.repository import menu_repo
from modules.system.role_menu.models import RoleMenu
from modules.system.role_menu.repository import rolemenu_repo
from core.database import get_db_session
# 首次保存:生成 查询(1)/添加(5) 两个按钮节点
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
assert set(first) == {1, 5}
# 模拟角色已把"添加"按钮授权给角色 1
get_db_session().add(RoleMenu(role_id=1, menu_id=first[5]))
sqlite_db.commit()
# 再次编辑菜单(勾选增加 修改/10)→ 已勾选旧节点必须复用原 id
set_permission(0, [1, 5, 10], '用户管理', '/system/user', 1, 1)
second = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
assert set(second) == {1, 5, 10}
assert second[1] == first[1] # 查询节点 id 未变
assert second[5] == first[5] # 添加节点 id 未变 → role_menu 引用仍有效
assert second[10] not in first.values() # 修改为新增节点
# role_menu 授权仍指向有效(is_delete=0)的按钮节点
refs = rolemenu_repo.filter(RoleMenu.menu_id == first[5]).all()
assert len(refs) == 1
assert menu_repo.get_by_id(first[5]) is not None
def test_set_permission_soft_deletes_unchecked_nodes(sqlite_db):
from modules.system.menu.service import set_permission
from modules.system.menu.repository import menu_repo
set_permission(0, [1, 5], '用户管理', '/system/user', 1, 1)
first = {node.sort: node.id for node in menu_repo.get_all(parent_id=1, type=1)}
assert set(first) == {1, 5}
# 编辑时去掉 添加(5) → 旧节点软删,不再参与鉴权
set_permission(0, [1], '用户管理', '/system/user', 1, 1)
remaining = menu_repo.get_all(parent_id=1, type=1)
assert [node.sort for node in remaining] == [1]
assert menu_repo.get_by_id(first[5]) is None # 已软删,等值查询不可见
# ============================================================
# M-1: update_user_status 变更状态后失效权限缓存
# ============================================================
def test_update_user_status_invalidates_perm_cache(sqlite_db, monkeypatch):
import asyncio
from modules.system.user import service as user_svc
from modules.system.user.schemas import UserStatusForm
from modules.system.user.repository import user_repo
# 预置超管占位记录(id=1),使被测用户获得非超管 ID,避开超管写保护
user_repo.create(
username='superadmin', realname='r', nickname='n', gender=1, avatar='a',
mobile='1', email='a@b.com', province_code='11', city_code='1101',
district_code='110101', street_code='', address='北京市', status=1, sort=1,
)
user = user_repo.create(
username='u1', realname='r', nickname='n', gender=1, avatar='a',
mobile='1', email='a@b.com', province_code='11', city_code='1101',
district_code='110101', street_code='', address='北京市', status=1, sort=1,
)
invalidated = []
async def _recorder(uid):
invalidated.append(uid)
monkeypatch.setattr(user_svc, 'invalidate_user_perms', _recorder)
resp = asyncio.run(user_svc.update_user_status(
_make_request(), UserStatusForm(id=user.id, status=2)))
assert json.loads(resp.body)['code'] == 0
assert invalidated == [user.id]
# ============================================================
# M-2: invalidate_role_perms 清理角色下所有用户缓存(DB 查询走线程池)
# ============================================================
@pytest.mark.asyncio
async def test_invalidate_role_perms_clears_all_user_caches(sqlite_db, monkeypatch):
import fakeredis.aioredis
from modules.system.user_role.models import UserRole
from core.database import get_db_session
from utils import perm_cache
# 构造:角色2 → 用户5/6
get_db_session().add_all([
UserRole(user_id=5, role_id=2),
UserRole(user_id=6, role_id=2),
])
sqlite_db.commit()
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
await fake_redis.set('perm:version', '0')
await fake_redis.set('perm:list:5:0', '["sys:user:add"]')
await fake_redis.set('perm:menu:6:0', '[]')
await fake_redis.set('perm:list:99:0', '["sys:user:page"]') # 无关用户,不应被清
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
await perm_cache.invalidate_role_perms(2)
# 该角色下所有用户的权限/菜单缓存被清(DB 查询经 asyncio.to_thread 执行)
assert await fake_redis.get('perm:list:5:0') is None
assert await fake_redis.get('perm:menu:6:0') is None
# 无关用户缓存不受影响
assert await fake_redis.get('perm:list:99:0') == '["sys:user:page"]'
# ============================================================
# M-3: 失效缓存失败时 fail-closed 降级递增全局版本号,避免旧权限残留 2 小时
# ============================================================
@pytest.mark.asyncio
async def test_invalidate_user_perms_success_no_fallback(monkeypatch):
import fakeredis.aioredis
from utils import perm_cache
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
await fake_redis.set('perm:version', '0')
await fake_redis.set('perm:list:5:0', '["sys:user:add"]')
await fake_redis.set('perm:menu:5:0', '[]')
monkeypatch.setattr(perm_cache, 'get_redis', lambda: fake_redis)
calls = []
async def _incr_recorder():
calls.append('incr')
return 1
monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder)
await perm_cache.invalidate_user_perms(5)
# 删除成功:不触发降级版本号递增,缓存键已删除
assert calls == []
assert await fake_redis.get('perm:list:5:0') is None
assert await fake_redis.get('perm:menu:5:0') is None
@pytest.mark.asyncio
async def test_invalidate_user_perms_failure_falls_back_to_version_bump(monkeypatch):
from utils import perm_cache
def _raise(*args, **kwargs):
raise RuntimeError('redis down')
calls = []
async def _incr_recorder():
calls.append('incr')
return 1
monkeypatch.setattr(perm_cache, 'get_redis', _raise)
monkeypatch.setattr(perm_cache, '_incr_version', _incr_recorder)
await perm_cache.invalidate_user_perms(5)
# 删除失败 → fail-closed 降级递增全局版本号,孤立旧缓存,杜绝残留
assert calls == ['incr']
# ============================================================
# M-4: save_content 白名单清洗防存储型 XSS
# ============================================================
def test_save_content_sanitizes_xss():
from utils.rich_text import save_content
out = save_content('<p>x<script>alert(1)</script></p>', '标题', 'notice')
assert '<script>' not in out # 脚本标签被剥离(文本内容残留为无害纯文本)
out = save_content('<a href="javascript:alert(1)">x</a>', '标题', 'notice')
assert 'javascript:' not in out
assert '<a>' in out
out = save_content('<img src="x.png" onerror="alert(1)">', '标题', 'notice')
assert 'onerror' not in out
out = save_content('<div style="color:red;width:expression(alert(1))">t</div>', '标题', 'notice')
assert 'expression' not in out
# 安全样式应保留
out = save_content('<div style="color:red;text-align:center">ok</div>', '标题', 'notice')
assert out == '<div style="color:red;text-align:center;">ok</div>'
# ============================================================
# M-5: 职级导入逐行校验,非法行跳过并返回行号原因
# ============================================================
def test_import_level_skips_invalid_rows(sqlite_db, monkeypatch, tmp_path):
import asyncio
import json
from openpyxl import Workbook
import modules.system.level.service as level_svc
from modules.system.level.repository import level_repo
# 构造 Excel:表头 + 2 行合法 + 3 行非法(空名称/重复名/非法排序)
up = tmp_path / 'uploads'
up.mkdir(parents=True)
wb = Workbook()
ws = wb.worksheets[0]
ws.append(['职级ID', '职级名称', '职级排序', '职级状态', '创建时间', '更新时间'])
ws.append([None, '高级', 1, '正常'])
ws.append([None, '', 2, '正常']) # 空名称
ws.append([None, '高级', 3, '正常']) # 名称重复
ws.append([None, '特级', 'abc', '正常']) # 排序非数字
ws.append([None, '中级', 5, '禁用'])
xlsx = up / 'import.xlsx'
wb.save(xlsx)
monkeypatch.setattr(level_svc, 'FASTAPI_UPLOAD_DIR', str(up))
async def fake_upload(file, exts):
from starlette.responses import JSONResponse
return JSONResponse({"code": 0, "data": {"filePath": "/import.xlsx"}})
monkeypatch.setattr(level_svc.upload, 'upload_file', fake_upload)
resp = asyncio.run(level_svc.level_service.import_level(_make_request(), None))
body = json.loads(resp.body)
assert body['code'] == 0
assert body['data']['success'] == 2
assert body['data']['fail'] == 3
assert any('第3行' in e for e in body['data']['errors'])
assert any('第4行' in e for e in body['data']['errors'])
assert any('第5行' in e for e in body['data']['errors'])
assert level_repo.get_one(name='高级') is not None
assert level_repo.get_one(name='中级') is not None
assert level_repo.count() == 2
+219
View File
@@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
"""中间件修复冒烟测试:验证响应体重建、事务提交/回滚、操作日志捕获。
无需真实 MySQL/Redis:SessionLocal 与操作日志记录均被 monkeypatch。
"""
import asyncio
import sys
import pytest
sys.path.insert(0, 'src')
class FakeSession:
def __init__(self):
self.commits = 0
self.rollbacks = 0
self.closed = False
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
self.closed = True
@pytest.fixture
def fake_session():
return FakeSession()
def _make_app(fake_session, monkeypatch, log_sink):
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import middleware.db_session as mw_db
import middleware.operation_log as mw_op
from modules.operation_log import service as op_service
# 打桩 DB 会话与操作日志记录,避免真实依赖
monkeypatch.setattr(mw_db, "SessionLocal", lambda: fake_session)
monkeypatch.setattr(
op_service, "record_operation_log",
lambda request, **kw: log_sink.append(kw))
app = FastAPI()
@app.middleware("http")
async def inject_user(request: Request, call_next):
request.state.user_id = 1
request.state.username = "tester"
return await call_next(request)
# 与 create_app 相同的嵌套顺序:db_session 内层,operation_log 外层
app.add_middleware(
BaseHTTPMiddleware, dispatch=mw_db.db_session_middleware)
app.add_middleware(
BaseHTTPMiddleware, dispatch=mw_op.operation_log_middleware)
app.add_middleware(BaseHTTPMiddleware, dispatch=inject_user)
@app.post("/ok")
def ok():
return {"code": 0, "data": None, "msg": "操作成功", "ok": True}
@app.post("/fail")
def fail():
from starlette.responses import JSONResponse
return JSONResponse(
{"code": 1, "data": None, "msg": "业务失败", "ok": False})
@app.post("/boom")
def boom():
raise RuntimeError("unexpected error")
@app.get("/read")
def read():
return {"code": 0, "data": {"x": 1}}
return app
def test_response_body_helpers():
"""read_response_body / rebuild_response 基础行为。"""
from starlette.responses import JSONResponse
from utils.response_body import read_response_body, rebuild_response
# 普通 Response:body 可直接读,consumed=False
resp = JSONResponse({"code": 0})
body, consumed = asyncio.run(read_response_body(resp))
assert consumed is False
assert b'"code"' in body
# 流式响应:需消费 body_iterator,consumed=True
from starlette.responses import StreamingResponse
resp2 = StreamingResponse(iter([b'{"code": 1, "msg": "x"}']))
body2, consumed2 = asyncio.run(read_response_body(resp2))
assert consumed2 is True
assert body2 == b'{"code": 1, "msg": "x"}'
rebuilt = rebuild_response(resp2, body2)
assert rebuilt.status_code == 200
assert rebuilt.body == body2
def test_write_ok_commits(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.post("/ok")
assert resp.json()["code"] == 0
assert fake_session.commits == 1
assert fake_session.rollbacks == 0
assert fake_session.closed
assert len(log_sink) == 1
assert log_sink[0]["status"] == 0
def test_write_fail_rolls_back(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.post("/fail")
# 客户端仍能收到完整响应体(重建后未被消费掉)
assert resp.json()["code"] == 1
assert resp.json()["msg"] == "业务失败"
assert fake_session.commits == 0
assert fake_session.rollbacks == 1
assert fake_session.closed
assert len(log_sink) == 1
assert log_sink[0]["status"] == 1
assert "业务失败" in log_sink[0]["error"]
def test_read_does_not_touch_tx(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.get("/read")
assert resp.json()["code"] == 0
assert fake_session.commits == 0
assert fake_session.rollbacks == 0
assert len(log_sink) == 0 # GET 不记录操作日志
def test_unexpected_exception_rolls_back_and_client_gets_500(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.post("/boom")
assert resp.status_code == 500
assert fake_session.commits == 0
assert fake_session.rollbacks == 1
assert len(log_sink) == 1
assert log_sink[0]["status"] == 1
def test_is_business_success_fails_closed_on_non_json():
"""非 JSON / 空体 / 非对象响应一律视为失败,仅 JSON code==0 提交。"""
from middleware import db_session as mw_db
assert mw_db._is_business_success(b'{"code":0,"msg":"ok"}') is True
assert mw_db._is_business_success(b'{"code":1,"msg":"fail"}') is False
# 非 JSON 2xx 响应体:无法确认业务成功 → 失败(回滚)
assert mw_db._is_business_success(b'<html>ok</html>') is False
# 空体(如 204)→ 失败
assert mw_db._is_business_success(b'') is False
# 非对象 JSON(数组)→ 失败
assert mw_db._is_business_success(b'[1,2,3]') is False
# 非法 JSON → 失败
assert mw_db._is_business_success(b'not json') is False
def test_http_exception_handler_unifies_404_405_format():
"""注册核心异常处理器后,404/405 等 Starlette HTTP 异常统一为 {code:1}+200。
不加此处理器时,Starlette 默认 HTTPException 处理器优先级高于 Exception
兜底,会返回非 200 的 {"detail": ...},破坏全站统一响应格式。
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.app import register_exception
app = FastAPI()
@app.get("/exists")
def exists():
return {"code": 0, "data": None, "msg": "ok", "ok": True}
register_exception(app)
with TestClient(app) as client:
# 404:不存在路由
resp = client.get("/nonexistent")
body = resp.json()
assert resp.status_code == 200
assert body["code"] == 1
assert body["ok"] is False
assert body["data"] is None
assert body["msg"] == "Not Found"
# 405:方法不允许
resp = client.post("/exists")
body = resp.json()
assert resp.status_code == 200
assert body["code"] == 1
assert body["ok"] is False
assert body["msg"] == "Method Not Allowed"
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""富文本文件迁移路径修复测试:验证临时文件迁移与URL往返转换。
在临时目录中构建真实的 uploads/temp 结构,确认
save_file 迁移逻辑与路径拼接正确(含 Windows 分隔符场景)。
"""
import sys
import pytest
sys.path.insert(0, 'src')
import utils.rich_text as rt
@pytest.fixture
def fake_uploads(monkeypatch, tmp_path):
"""将 rich_text 的路径常量指向 tmp 下的临时目录。"""
up = tmp_path / 'uploads'
temp = up / 'temp'
temp.mkdir(parents=True)
monkeypatch.setattr(rt, 'FASTAPI_UPLOAD_DIR', str(up))
monkeypatch.setattr(rt, 'FASTAPI_TEMP_PATH', str(temp))
monkeypatch.setattr(rt, 'FASTAPI_FILE_URL', 'http://img.test')
return up, temp
def test_already_in_formal_dir_returns_relative_url(fake_uploads):
up, temp = fake_uploads
target = up / 'article' / '20240801' / 'a.png'
target.parent.mkdir(parents=True)
target.write_bytes(b'x')
result = rt.save_file('http://img.test/article/20240801/a.png', 'article')
assert result == '/article/20240801/a.png'
assert target.exists() # 不迁移、不移动
def test_temp_file_migrates_to_formal_dir(fake_uploads):
import time
up, temp = fake_uploads
src = temp / '20240801' / 'b.png'
src.parent.mkdir(parents=True)
src.write_bytes(b'data')
result = rt.save_file('/temp/20240801/b.png', 'article')
today = time.strftime('%Y%m%d')
# 迁移后返回正式目录相对URL(目标日期目录为当前日期,仅迁移文件名)
assert result == f'/article/{today}/b.png'
assert not src.exists() # 原临时文件已移动
dest = up / 'article' / today / 'b.png'
assert dest.read_bytes() == b'data'
def test_temp_file_migrates_from_absolute_url(fake_uploads):
import time
up, temp = fake_uploads
src = temp / '20240802' / 'c.png'
src.parent.mkdir(parents=True)
src.write_bytes(b'abc')
result = rt.save_file('http://img.test/temp/20240802/c.png', 'article')
today = time.strftime('%Y%m%d')
assert result == f'/article/{today}/c.png'
assert (up / 'article' / today / 'c.png').read_bytes() == b'abc'
def test_remote_url_not_supported(fake_uploads):
assert rt.save_file('https://other.host/x.png', 'article') == ''
def test_empty_url_returns_empty(fake_uploads):
assert rt.save_file('', 'article') == ''
assert rt.save_file(None, 'article') == ''
def test_disk_to_url_roundtrip(fake_uploads):
up, temp = fake_uploads
target = up / 'article' / '20240801' / 'd.png'
target.parent.mkdir(parents=True)
target.write_bytes(b'z')
# 正式目录文件:磁盘路径 -> 相对URL -> 磁盘路径 往返一致
disk = rt._url_to_disk_path('/article/20240801/d.png')
assert disk == str(target)
assert rt._disk_to_url_path(disk) == '/article/20240801/d.png'
+113
View File
@@ -0,0 +1,113 @@
"""启动期安全配置校验 fail-close + 上传白名单双层机制(内置集合 ∩ 配置)。"""
import pytest
import core.config as config
from constants.constants import BUILTIN_UPLOAD_EXTS
def _set(monkeypatch, **kwargs):
for name, value in kwargs.items():
monkeypatch.setattr(config, name, value)
def test_validate_config_passes_with_valid_values():
# 默认 .env 为有效配置,不应抛异常
config.validate_config()
# ============ 上传白名单:有效集合 = 配置 ∩ 内置集合,未配置回落内置集合 ============
def test_effective_upload_exts_falls_back_to_builtin_when_empty():
assert set(config.effective_upload_exts('')) == BUILTIN_UPLOAD_EXTS
assert set(config.effective_upload_exts(' ')) == BUILTIN_UPLOAD_EXTS
def test_effective_upload_exts_intersects_with_builtin():
# 配置含内置集合外的危险后缀 → 交集兜底,仅保留允许项
assert config.effective_upload_exts('.jpg,.svg,.py') == ['.jpg']
def test_effective_upload_exts_empty_when_all_outside_builtin():
assert config.effective_upload_exts('.svg,.webp,.exe') == []
def test_effective_upload_exts_normalizes_case_and_space():
assert config.effective_upload_exts('.JPG, .PNG, txt') == ['.jpg', '.png', '.txt']
# ============ 启动检测 validate_config ============
def test_validate_config_passes_when_upload_exts_empty(monkeypatch):
# 方案A:未配置 → 回落内置智能集合,不拒绝启动
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='')
config.validate_config()
def test_validate_config_passes_when_upload_exts_blank(monkeypatch):
_set(monkeypatch, UPLOAD_ALLOWED_EXTS=' ')
config.validate_config()
def test_validate_config_passes_when_upload_exts_subset(monkeypatch):
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.jpg,.png')
config.validate_config()
def test_validate_config_fails_when_upload_exts_all_outside_builtin(monkeypatch):
# 已配置但交集为空(配置的全是不允许的后缀)→ 有效白名单为空,拒绝启动
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp,.py')
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
assert 'UPLOAD_ALLOWED_EXTS' in str(exc_info.value)
def test_validate_config_fails_when_jwt_salt_empty(monkeypatch):
_set(monkeypatch, JWT_SALT='')
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
assert 'JWT_SALT' in str(exc_info.value)
def test_validate_config_fails_when_jwt_salt_too_short(monkeypatch):
_set(monkeypatch, JWT_SALT='short-secret-123') # 16 字节 < 32
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
assert 'JWT_SALT' in str(exc_info.value)
assert '32' in str(exc_info.value)
def test_validate_config_reports_both_errors(monkeypatch):
_set(monkeypatch, UPLOAD_ALLOWED_EXTS='.svg,.webp', JWT_SALT='weak')
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
message = str(exc_info.value)
assert 'UPLOAD_ALLOWED_EXTS' in message
assert 'JWT_SALT' in message
# ============ 凭据 fail-close:DB_PASSWORD / REDIS_PASSWORD ============
def test_validate_config_fails_when_db_password_empty(monkeypatch):
_set(monkeypatch, DB_DRIVER='mysql', DB_PASSWORD='')
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
assert 'DB_PASSWORD' in str(exc_info.value)
def test_validate_config_passes_when_sqlite_without_password(monkeypatch):
# sqlite 无需密码,不应因 DB_PASSWORD 为空而拒绝
_set(monkeypatch, DB_DRIVER='sqlite', DB_PASSWORD='')
config.validate_config()
def test_validate_config_fails_when_redis_auth_without_password(monkeypatch):
_set(monkeypatch, REDIS_AUTH=True, REDIS_PASSWORD='')
with pytest.raises(RuntimeError) as exc_info:
config.validate_config()
assert 'REDIS_PASSWORD' in str(exc_info.value)
def test_validate_config_passes_when_redis_no_auth_without_password(monkeypatch):
# REDIS_AUTH=False 时无需密码
_set(monkeypatch, REDIS_AUTH=False, REDIS_PASSWORD='')
config.validate_config()
+131
View File
@@ -0,0 +1,131 @@
"""多数据库接入:URL 构建 / 驱动归一化 / 方言感知引擎。
覆盖 DB_DRIVER=mysql|postgresql|mssql|sqlite|oracle 的连接串生成、别名归一化、
未知驱动 fail-fast,以及 sqlite 引擎构建不引入 pymysql。
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base
import core.config as config
import core.config.database as database
from core.database import build_engine
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
# ============ build_database_url 各驱动 ============
def test_mysql_url_shape():
url = database.build_database_url('mysql')
assert url.startswith('mysql+pymysql://')
assert url.endswith('?charset=utf8mb4')
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
def test_postgresql_url_shape():
url = database.build_database_url('postgresql')
assert url.startswith('postgresql+psycopg://')
assert 'charset' not in url
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
def test_mssql_url_shape():
url = database.build_database_url('mssql')
assert url.startswith('mssql+pymssql://')
assert url.endswith('?charset=utf8')
assert f'@{config.DB_HOST}:{config.DB_PORT}/{config.DB_DATABASE}' in url
def test_oracle_url_shape():
url = database.build_database_url('oracle')
assert url.startswith('oracle+oracledb://')
assert 'service_name=' in url
assert f'@{config.DB_HOST}:{config.DB_PORT}/' in url
def test_sqlite_url_uses_db_database_as_path(monkeypatch):
monkeypatch.setattr(database, 'DB_DATABASE', 'data/dev.db')
assert database.build_database_url('sqlite') == 'sqlite:///./data/dev.db'
# DB_DATABASE 为绝对路径时直接透传
monkeypatch.setattr(database, 'DB_DATABASE', 'C:/apps/dev.db')
assert database.build_database_url('sqlite') == 'sqlite:///C:/apps/dev.db'
def test_sqlite_url_absolute_path():
assert database._sqlite_url('C:/apps/dev.db') == 'sqlite:///C:/apps/dev.db'
if os.name == 'posix':
# POSIX 绝对路径 → sqlite:////abs/x.db(Windows 下 /abs 仅相对当前盘,属相对路径)
assert database._sqlite_url('/abs/x.db') == 'sqlite:////abs/x.db'
def test_url_quote_plus_credentials(monkeypatch):
monkeypatch.setattr(database, 'DB_USERNAME', 'user name')
monkeypatch.setattr(database, 'DB_PASSWORD', 'p@ss/w:?')
url = database.build_database_url('mysql')
assert 'user+name' in url
assert 'p%40ss%2Fw%3A%3F' in url
# ============ _normalize_driver 别名 / 大小写 / 未知 ============
def test_normalize_driver_aliases_and_case():
assert database._normalize_driver(' POSTGRES ') == 'postgresql'
assert database._normalize_driver('postgres') == 'postgresql'
assert database._normalize_driver('sqlserver') == 'mssql'
assert database._normalize_driver('sql_server') == 'mssql'
assert database._normalize_driver("'sqlite3'") == 'sqlite'
assert database._normalize_driver('MySQL') == 'mysql'
assert database._normalize_driver('oracle') == 'oracle'
def test_build_database_url_unknown_driver_raises():
with pytest.raises(ValueError) as exc_info:
database.build_database_url('db2') # 真正不支持的驱动
msg = str(exc_info.value)
assert '不支持' in msg
assert 'mysql' in msg and 'sqlite' in msg
# ============ 方言感知引擎 ============
def test_build_engine_sqlite_smoke():
engine = build_engine(driver='sqlite', database_url='sqlite:///:memory:')
TestBase = declarative_base()
class T(TestBase):
__tablename__ = 't_smoke'
id = Column(Integer, primary_key=True)
name = Column(String(50))
TestBase.metadata.create_all(engine)
with engine.begin() as conn:
conn.execute(T.__table__.insert(), {'id': 1, 'name': 'x'})
with engine.connect() as conn:
row = conn.execute(T.__table__.select()).first()
assert row.name == 'x'
engine.dispose()
def test_sqlite_engine_does_not_import_pymysql():
"""sqlite 路径构建引擎不得引入 pymysql(子进程隔离,避免会话内已有缓存)。"""
code = (
"import sys\n"
"sys.path.insert(0, 'src')\n"
"import core.database\n"
"print('pymysql' in sys.modules)\n"
)
env = {**os.environ, 'DB_DRIVER': 'sqlite'}
result = subprocess.run(
[sys.executable, '-c', code],
capture_output=True, text=True, cwd=_PROJECT_ROOT, env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == 'False'
+46
View File
@@ -0,0 +1,46 @@
"""基础设施资源上限回归:日志文件轮转 + Redis 连接池上限。"""
import logging
import logging.handlers
import pytest
from core.config.redis import REDIS_MAX_CONNECTIONS
def test_file_handler_is_rotating():
from core.logger import setup_logging
setup_logging()
root = logging.getLogger()
file_handlers = [
h for h in root.handlers
if getattr(h, '_djangoadmin', False) and hasattr(h, 'baseFilename')
]
assert file_handlers, "应存在文件日志 handler"
for h in file_handlers:
# 文件日志必须使用 RotatingFileHandler(按大小轮转,防单文件无限增长)
assert isinstance(h, logging.handlers.RotatingFileHandler)
assert h.maxBytes > 0
assert h.backupCount > 0
@pytest.mark.asyncio
async def test_create_redis_pool_has_connection_cap():
from core.redis_client import create_redis_pool
redis = await create_redis_pool()
try:
# from_url 不发起连接,仅创建连接池;断言最大连接数已收敛(非默认 2**31)
assert redis.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
finally:
await redis.aclose()
def test_create_sync_redis_has_connection_cap():
from core.redis_client import create_sync_redis
client = create_sync_redis()
try:
assert client.connection_pool.max_connections == REDIS_MAX_CONNECTIONS
finally:
client.close()
+55 -3
View File
@@ -1,4 +1,9 @@
"""core/jwt.py 纯函数单元测试(不依赖 Redis/DB)。"""
"""core/jwt.py 单元测试(不依赖真实 Redis/DB)。"""
import logging
import time as time_module
import pytest
from core import jwt as core_jwt
@@ -16,14 +21,61 @@ def test_parse_invalid_token():
assert result["code"] == 1
def test_refresh_token():
@pytest.mark.asyncio
async def test_refresh_token():
token = core_jwt.create_token({"userId": 1})
result = core_jwt.refresh_token(token)
result = await core_jwt.refresh_token(token)
assert result["code"] == 0
assert result["data"]["access_token"]
@pytest.mark.asyncio
async def test_refresh_blacklisted_token_rejected(monkeypatch):
"""已拉黑(注销)的令牌禁止刷新,防止黑名单轮换绕过。"""
token = core_jwt.create_token({"userId": 1})
async def _blacklisted(_token):
return True
monkeypatch.setattr(core_jwt, "is_token_blacklisted", _blacklisted)
result = await core_jwt.refresh_token(token)
assert result["code"] == 1
assert "注销" in result["msg"]
def test_get_token_expiration():
token = core_jwt.create_token({"userId": 1}, timeout=30)
exp = core_jwt.get_token_expiration(token)
assert exp is not None
@pytest.mark.asyncio
async def test_blacklist_check_fail_open_alert_throttled(monkeypatch, caplog):
"""Redis 异常时 fail-open 放行;告警节流:窗口内只记一条 ERROR,重复失败降级 DEBUG。"""
# 重置节流状态,保证测试确定性
monkeypatch.setattr(core_jwt, "_blacklist_check_last_alert", 0.0)
clock = {"t": 1000.0}
monkeypatch.setattr(time_module, "monotonic", lambda: clock["t"])
async def call():
return await core_jwt.is_token_blacklisted("some.token")
# 首次失败 → ERROR 告警 + fail-open 放行
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
assert await call() is False
assert any(r.levelno == logging.ERROR for r in caplog.records)
caplog.clear()
# 窗口内(+30s)重复失败 → 不再产生 ERROR
clock["t"] += 30
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
assert await call() is False
assert not any(r.levelno == logging.ERROR for r in caplog.records)
caplog.clear()
# 超过窗口(再 +60s)→ 再次 ERROR
clock["t"] += 60
with caplog.at_level(logging.DEBUG, logger="core.jwt"):
assert await call() is False
assert any(r.levelno == logging.ERROR for r in caplog.records)
+1 -1
View File
@@ -1,6 +1,6 @@
"""shared/pagination.py 分页工具单元测试。"""
from shared.pagination import get_page_params
from constants.pagination import get_page_params
def test_defaults():
+27
View File
@@ -0,0 +1,27 @@
"""core/redis_client.py incr_with_expire 原子自增工具测试。"""
import asyncio
from core.redis_client import incr_with_expire, _INCR_EXPIRE_LUA
class _FakeRedis:
def __init__(self):
self.calls = []
async def eval(self, script, numkeys, *keys_and_args):
self.calls.append((script, numkeys, keys_and_args))
return 1
def test_incr_with_expire_evals_atomic_script():
redis = _FakeRedis()
assert asyncio.run(incr_with_expire(redis, 'rl:1.2.3.4', 60)) == 1
assert len(redis.calls) == 1
script, numkeys, args = redis.calls[0]
assert numkeys == 1
assert args == ('rl:1.2.3.4', 60)
# 脚本内必须同时包含 incr 与 expire,且 expire 仅在首次创建时执行
assert 'incr' in script and 'expire' in script
assert 'count == 1' in script
+23
View File
@@ -26,3 +26,26 @@ def test_failed_shape():
assert body["code"] == 1
assert body["ok"] is False
assert body["msg"] == "错误信息"
def test_page_shape():
resp = R.page(data=[{"id": 1}, {"id": 2}], total=10, current=1, size=5)
body = json.loads(resp.body)
assert body["code"] == 0
assert body["ok"] is True
assert body["msg"] == "操作成功"
d = body["data"]
assert d["records"] == [{"id": 1}, {"id": 2}]
assert d["total"] == 10
assert d["size"] == 5
assert d["current"] == 1
assert d["pages"] == 2
def test_page_empty():
resp = R.page(data=[], total=0, current=1, size=10)
body = json.loads(resp.body)
d = body["data"]
assert d["records"] == []
assert d["total"] == 0
assert d["pages"] == 0
View File
@@ -0,0 +1,30 @@
"""中间件注册顺序:限流须在 DB 会话/操作日志之外(更外层),
否则被限流的请求仍先开 DB 会话、仍写操作日志,DoS 时放大 DB 与日志负担。"""
from core.app import create_app
def _middleware_names(app):
"""按执行顺序(列表最前 = 最外层/先执行)提取各中间件名。"""
names = []
for m in app.user_middleware:
dispatch = getattr(m, 'kwargs', {}).get('dispatch')
if dispatch is not None:
names.append(getattr(dispatch, '__name__', str(dispatch)))
else:
names.append(getattr(m.cls, '__name__', str(m)))
return names
def test_rate_limit_runs_before_db_session_and_operation_log():
names = _middleware_names(create_app())
# 限流必须比 DB 会话 / 操作日志更外层(先执行),拒绝请求不进入 DB 与日志
assert 'rate_limit_middleware' in names
assert names.index('rate_limit_middleware') < names.index('db_session_middleware')
assert names.index('rate_limit_middleware') < names.index('operation_log_middleware')
def test_cors_is_outermost():
names = _middleware_names(create_app())
# CORS 在最外层,直接返回的响应也带 CORS 头
assert 'CORSMiddleware' in names
assert names.index('CORSMiddleware') == 0
+120
View File
@@ -0,0 +1,120 @@
"""middleware/operation_log.py 操作日志中间件单元测试:日志写入延迟到响应后台任务,不阻塞响应。"""
import asyncio
import types
import pytest
from starlette.requests import Request
from starlette.responses import JSONResponse
import middleware.operation_log as op_middleware
def _make_request(method='PUT', path='/api/v1/demo', user_id=1):
scope = {
'type': 'http',
'method': method,
'path': path,
'headers': [(b'user-agent', b'test-agent')],
'query_string': b'',
'route': types.SimpleNamespace(summary='测试操作'),
}
request = Request(scope)
request.state.user_id = user_id
return request
async def _run_middleware(request):
async def call_next(request):
return JSONResponse(content={'code': 0, 'msg': 'ok'})
return await op_middleware.operation_log_middleware(request, call_next)
def _patch_to_thread(monkeypatch, submitted):
"""记录提交到线程池的函数并同步执行,隔离真实线程调度。"""
async def fake_to_thread(func, *args, **kwargs):
submitted.append(func)
return func(*args, **kwargs)
monkeypatch.setattr(asyncio, 'to_thread', fake_to_thread)
def test_operation_log_deferred_to_response_background(monkeypatch):
"""日志写入挂到 response.background,响应返回后异步落库,不阻塞响应。"""
import modules.operation_log.service as op_service
captured = {}
monkeypatch.setattr(op_service, 'record_operation_log',
lambda request, title, **kw: captured.update(kw) or None)
resp = asyncio.run(_run_middleware(_make_request()))
assert resp.status_code == 200
# 响应上挂载后台任务(而非在响应返回前同步执行日志写入)
assert resp.background is not None
# 后台任务执行时调用 record_operation_log 写入日志
asyncio.run(resp.background())
assert captured['status'] == 0
assert captured['consume_time'] >= 0
assert 'ok' in captured['result'] # result 采样非空
def test_operation_log_records_failure_status(monkeypatch):
"""业务失败(code=1)在后台任务中记录 error 与 status=1。"""
import modules.operation_log.service as op_service
captured = {}
monkeypatch.setattr(op_service, 'record_operation_log',
lambda request, title, **kw: captured.update(kw) or None)
async def call_next(request):
return JSONResponse(content={'code': 1, 'msg': '权限不足'})
resp = asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
asyncio.run(resp.background())
assert captured['status'] == 1
assert captured['error'] == '权限不足'
def test_operation_log_exception_path_writes_via_thread_pool(monkeypatch):
"""请求异常无响应可挂后台任务:走线程池同步写入(低频异常路径)。"""
import modules.operation_log.service as op_service
submitted = []
_patch_to_thread(monkeypatch, submitted)
captured = {}
monkeypatch.setattr(op_service, 'record_operation_log',
lambda request, title, **kw: captured.update(kw) or None)
async def call_next(request):
raise RuntimeError('boom')
with pytest.raises(RuntimeError):
asyncio.run(op_middleware.operation_log_middleware(_make_request(), call_next))
assert submitted == [op_service.record_operation_log]
assert captured['status'] == 1
assert captured['error'] == 'boom'
def test_operation_log_skipped_for_get_request(monkeypatch):
import modules.operation_log.service as op_service
submitted = []
_patch_to_thread(monkeypatch, submitted)
monkeypatch.setattr(op_service, 'record_operation_log', lambda *a, **k: None)
resp = asyncio.run(_run_middleware(_make_request(method='GET')))
assert resp.status_code == 200
assert submitted == []
def test_exclusion_uses_path_boundary_not_substring():
"""排除匹配按路径段边界,避免子串误伤其他模块路由。"""
# /api/v1/index/userInfo 是个人中心写操作,不应被子串 '/api/v1/index/user' 误排除
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/index/userInfo'))
# 挂载前缀下的写操作应排除
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/login'))
assert not op_middleware._should_log(_make_request(method='POST', path='/api/v1/upload/uploadFile'))
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/operationlog/delete/1'))
assert not op_middleware._should_log(_make_request(method='DELETE', path='/api/v1/loginlog/batchDelete'))
# 业务模块写操作应记录
assert op_middleware._should_log(_make_request(method='PUT', path='/api/v1/user/update/1'))
+105
View File
@@ -0,0 +1,105 @@
"""middleware/rate_limit.py 请求限流中间件单元测试。"""
from contextlib import asynccontextmanager
import fakeredis.aioredis
from fastapi import FastAPI
from fastapi.testclient import TestClient
import middleware.rate_limit as rate_limit_module
from middleware.rate_limit import rate_limit_middleware
def _make_app():
@asynccontextmanager
async def lifespan(app):
# 在应用运行的事件循环内创建 FakeRedis,避免跨循环绑定问题
app.state.redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/")
async def home():
return {"ok": True}
app.middleware("http")(rate_limit_middleware)
return app
def test_rate_limit_disabled_passes(monkeypatch):
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", False)
with TestClient(_make_app()) as client:
for _ in range(5):
r = client.get("/")
assert r.status_code == 200
assert r.json()["ok"] is True
async def _fake_sliding_window(redis, current_key, prev_key, window_seconds, window_index, now):
"""fakeredis 不支持 Lua eval,用等价实现打桩滑动窗口计数/加权语义。"""
cur = await redis.incr(current_key)
if cur == 1:
await redis.expire(current_key, window_seconds)
prev = int(await redis.get(prev_key) or 0)
elapsed = now - window_index * window_seconds
f = elapsed / window_seconds
f = max(0.0, min(1.0, f))
return prev * (1 - f) + cur
def test_rate_limit_exceeds_returns_business_error(monkeypatch):
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
with TestClient(_make_app()) as client:
for _ in range(3):
r = client.get("/")
assert r.status_code == 200
assert r.json()["ok"] is True
r = client.get("/")
assert r.status_code == 200
assert r.json()["code"] == 1
assert "过于频繁" in r.json()["msg"]
def test_rate_limit_window_accepts_after_reset(monkeypatch):
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 2)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 60)
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
with TestClient(_make_app()) as client:
for _ in range(2):
r = client.get("/")
assert r.json()["ok"] is True
r = client.get("/")
assert r.json()["code"] == 1
# 同一窗口内持续限流,不会因后续请求解除
r = client.get("/")
assert r.json()["code"] == 1
def test_rate_limit_sliding_window_prevents_boundary_burst(monkeypatch):
"""窗口边界处请求数按时间加权,不发生固定窗口的 2× 突发。"""
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_LIMIT", 3)
monkeypatch.setattr(rate_limit_module, "RATE_LIMIT_WINDOW_SECONDS", 100)
monkeypatch.setattr(rate_limit_module, "sliding_window_incr", _fake_sliding_window)
clock = {"t": 50.0}
monkeypatch.setattr(rate_limit_module.time, "time", lambda: clock["t"])
with TestClient(_make_app()) as client:
# 窗口0(t=50):2 个请求,配额充足,放行
for _ in range(2):
assert client.get("/").json()["ok"] is True
# 进入窗口1(t=150,f=0.5):上一窗口 2 个请求加权计入
clock["t"] = 150.0
# effective: 2*0.5+1=2 放行;2*0.5+2=3 放行;2*0.5+3=4 > 3 拒绝
for _ in range(2):
assert client.get("/").json()["ok"] is True
r = client.get("/")
assert r.json()["code"] == 1
@@ -0,0 +1,81 @@
"""middleware/upload_size_limit.py HTTP 层上传体积预拦截中间件单元测试。"""
import json
import pytest
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from middleware.upload_size_limit import upload_size_limit_middleware
from core.config.app import UPLOAD_HTTP_MAX_SIZE_MB, UPLOAD_MAX_SIZE_MB
def _make_request(method="POST", content_length=None):
headers = []
if content_length is not None:
headers.append((b"content-length", str(content_length).encode()))
scope = {
"type": "http", "asgi": {"version": "3.0"}, "http_version": "1.1",
"method": method, "scheme": "http", "path": "/upload/uploadFile",
"raw_path": b"/upload/uploadFile", "query_string": b"", "root_path": "",
"headers": headers, "client": ("127.0.0.1", 1234),
"server": ("127.0.0.1", 8031),
}
return Request(scope)
def _call(method="POST", content_length=None):
request = _make_request(method=method, content_length=content_length)
reached = {"called": False}
async def call_next(req):
reached["called"] = True
return Response(status_code=200)
return request, call_next, reached
@pytest.mark.asyncio
async def test_upload_size_middleware_rejects_oversized_body():
# 声明超过 HTTP 阈值 → 提前拦截,业务(读取请求体)不执行
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
request, call_next, reached = _call(content_length=limit + 1)
resp = await upload_size_limit_middleware(request, call_next)
assert isinstance(resp, JSONResponse)
body = json.loads(resp.body)
assert body["code"] == 1
assert "超过限制" in body["msg"]
assert f"{UPLOAD_MAX_SIZE_MB}MB" in body["msg"]
assert reached["called"] is False
@pytest.mark.asyncio
async def test_upload_size_middleware_passes_at_threshold():
# 恰好等于 HTTP 阈值 → 放行(精确大小由业务层校验)
limit = UPLOAD_HTTP_MAX_SIZE_MB * 1024 * 1024
request, call_next, reached = _call(content_length=limit)
resp = await upload_size_limit_middleware(request, call_next)
assert reached["called"] is True
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_upload_size_middleware_passes_chunked_no_length():
# 分块传输(无 Content-Length):头部无法判定,放行交由业务层流式兜底
request, call_next, reached = _call(content_length=None)
await upload_size_limit_middleware(request, call_next)
assert reached["called"] is True
@pytest.mark.asyncio
async def test_upload_size_middleware_ignores_get():
request, call_next, reached = _call(method="GET", content_length=99999999)
await upload_size_limit_middleware(request, call_next)
assert reached["called"] is True
+13
View File
@@ -0,0 +1,13 @@
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
import types
import pytest
@pytest.fixture
def fake_request():
"""模拟 FastAPI Request,仅提供 service 层用到的字段。"""
class FR:
state = types.SimpleNamespace(user_id=1, username="tester", realname="测试员")
query_params = {}
return FR()
@@ -0,0 +1,54 @@
"""auth/service.py _record_fail:失败计数原子自增,达到上限触发锁定。"""
import asyncio
import fakeredis.aioredis
import modules.auth.service as auth_svc
from modules.auth.service import _record_fail
async def _fake_incr_expire(redis, key, seconds):
"""fakeredis 不支持 Lua eval,用等价非原子实现打桩真实脚本的语义。"""
count = await redis.incr(key)
if count == 1:
await redis.expire(key, seconds)
return count
def test_record_fail_uses_atomic_incr_and_locks_at_limit(monkeypatch):
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 3)
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
async def run():
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
fail_key = auth_svc._fail_key('1.2.3.4', 'tester')
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
for _ in range(3):
await _record_fail(redis, '1.2.3.4', 'tester')
assert int(await redis.get(fail_key)) == 3
# 失败计数 key 首次创建即带 TTL,不会永久残留
assert await redis.ttl(fail_key) > 0
# 达到上限后设置锁定
assert await redis.get(lock_key) == '1'
assert await redis.ttl(lock_key) == 300
await redis.aclose()
asyncio.run(run())
def test_record_fail_below_limit_no_lock(monkeypatch):
monkeypatch.setattr(auth_svc, 'incr_with_expire', _fake_incr_expire)
monkeypatch.setattr(auth_svc, 'LOGIN_FAIL_MAX', 5)
monkeypatch.setattr(auth_svc, 'LOGIN_LOCK_SECONDS', 300)
async def run():
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
await _record_fail(redis, '1.2.3.4', 'tester')
lock_key = auth_svc._lock_key('1.2.3.4', 'tester')
assert await redis.get(lock_key) is None
await redis.aclose()
asyncio.run(run())
@@ -0,0 +1,89 @@
"""BaseService 分页/固定过滤钩子单元测试。
回归:_apply_page_filters 钩子须被 _build_page_query 调用,
否则 dict/config 项按所属字典/配置的固定过滤会失效。
"""
import json
from modules.dictionary.dict.models import Dict
from modules.dictionary.dict_item.models import DictItem
from modules.dictionary.dict_item.repository import dictitem_repo
from modules.dictionary.dict_item.service import dict_item_service
from modules.configuration.config_item.repository import configitem_repo
from modules.configuration.config_item.service import config_item_service
from modules.system.level.repository import level_repo
from modules.system.level.service import level_service
from utils import dict_util
def _resp_body(resp):
return json.loads(resp.body)
def test_dict_item_page_filters_by_dict_id(fake_request, sqlite_db):
dictitem_repo.create(name="性别男", value="1", dict_id=1, sort=1)
dictitem_repo.create(name="性别女", value="2", dict_id=1, sort=2)
dictitem_repo.create(name="状态启用", value="1", dict_id=2, sort=1)
fake_request.query_params = {"dict_id": 1}
resp = dict_item_service.get_page(fake_request)
body = _resp_body(resp)["data"]
assert len(body["records"]) == 2
assert all(row["dict_id"] == 1 for row in body["records"])
def test_config_item_page_filters_by_config_id(fake_request, sqlite_db):
configitem_repo.create(title="站点名", code="site_name", value="v", type="text", config_id=3, sort=1)
configitem_repo.create(title="LOGO", code="logo", value="x", type="image", config_id=4, sort=1)
# type_name 已由数据字典驱动:种入 config_item_type 并清空进程缓存,避免取到空字典
d = Dict(name="配置项类型", code="config_item_type", sort=1)
sqlite_db.add(d)
sqlite_db.flush()
sqlite_db.add(DictItem(name="单行文本", value="text", dict_id=d.id, sort=1))
sqlite_db.flush()
dict_util._dict_cache.clear()
fake_request.query_params = {"config_id": 3}
resp = config_item_service.get_page(fake_request)
body = _resp_body(resp)["data"]
assert len(body["records"]) == 1
assert body["records"][0]["config_id"] == 3
# 列表行序列化带 type_name,详情不含
assert body["records"][0]["type_name"] == "单行文本"
def test_page_like_eq_order(fake_request, sqlite_db):
level_repo.create(name="初级", status=1, sort=3)
level_repo.create(name="高级", status=1, sort=1)
level_repo.create(name="资深", status=2, sort=2)
# 等值 status=1 + 排序 sort asc
fake_request.query_params = {"status": 1}
resp = level_service.get_page(fake_request)
body = _resp_body(resp)["data"]
assert [row["name"] for row in body["records"]] == ["高级", "初级"]
# 模糊 name 包含"级"(初级、高级匹配,资深不匹配)
fake_request.query_params = {"name": "级"}
resp = level_service.get_page(fake_request)
assert _resp_body(resp)["data"]["total"] == 2
def test_delete_success(fake_request, sqlite_db):
obj = level_repo.create(name="待删除", status=1, sort=9)
resp = level_service.delete(str(obj.id))
body = _resp_body(resp)
assert body["code"] == 0
assert body["msg"] == "本次共删除1条数据"
assert level_repo.get_by_id(obj.id) is None
def test_delete_missing_id_failed(fake_request, sqlite_db):
resp = level_service.delete("999")
assert _resp_body(resp)["msg"] == "记录不存在"
def test_delete_empty_ids_failed(fake_request, sqlite_db):
resp = level_service.delete("")
assert _resp_body(resp)["msg"] == "记录ID不存在"
@@ -0,0 +1,79 @@
"""category service 单元测试(回归删除父级时禁止有子级)。"""
import json
from modules.category import service as category_service
from modules.category.models import Category
from modules.category.repository import category_repo
from modules.category.schemas import CategoryForm
def _resp_body(resp):
return json.loads(resp.body)
def _form(**overrides):
data = dict(categoryName="测试分类", categoryCode="C001", parentId=0, sort=1, note=None, type=1)
data.update(overrides)
return CategoryForm(**data)
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
category_service.add_category(fake_request, _form())
parent = category_repo.filter(Category.category_name == "测试分类").first()
category_service.add_category(
fake_request,
_form(categoryName="子分类", categoryCode="C001-1", parentId=parent.id),
)
child = category_repo.filter(Category.category_name == "子分类").first()
resp = category_service.delete_category(str(parent.id))
assert _resp_body(resp)["code"] != 0
# 父级与子级均保持生效
assert category_repo.get_by_id(parent.id).is_delete == 0
assert category_repo.get_by_id(child.id).is_delete == 0
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
category_service.add_category(fake_request, _form())
leaf = category_repo.filter(Category.category_name == "测试分类").first()
resp = category_service.delete_category(str(leaf.id))
assert _resp_body(resp)["code"] == 0
assert category_repo.filter(Category.id == leaf.id).first().is_delete == 1
# ============ 分类树成环检测:parent_id 存在性 + 自身/下级 校验 ============
def test_add_category_rejects_missing_parent(fake_request, sqlite_db):
resp = category_service.add_category(fake_request, _form(parentId=9999))
body = _resp_body(resp)
assert body["code"] != 0
assert "上级分类不存在" in body["msg"]
def test_update_category_rejects_self_parent(fake_request, sqlite_db):
category_service.add_category(fake_request, _form())
cat = category_repo.filter(Category.category_name == "测试分类").first()
resp = category_service.update_category(fake_request, _form(id=cat.id, parentId=cat.id))
body = _resp_body(resp)
assert body["code"] != 0
assert "自身" in body["msg"]
# 原记录未被改动
assert category_repo.get_by_id(cat.id).parent_id == 0
def test_update_category_rejects_descendant_parent(fake_request, sqlite_db):
category_service.add_category(fake_request, _form())
parent = category_repo.filter(Category.category_name == "测试分类").first()
category_service.add_category(fake_request, _form(categoryName="子分类", parentId=parent.id))
child = category_repo.filter(Category.category_name == "子分类").first()
# 把父级设为自己的子级 → 成环,拒绝
resp = category_service.update_category(
fake_request, _form(id=parent.id, categoryName="测试分类", parentId=child.id))
body = _resp_body(resp)
assert body["code"] != 0
assert "下级" in body["msg"]
assert category_repo.get_by_id(parent.id).parent_id == 0
+38
View File
@@ -0,0 +1,38 @@
"""city service 单元测试(回归 haveChild 尾部逗号导致恒为元组/恒 truthy 的 bug)。"""
import json
from modules.city import service as city_service
from modules.city.models import City
from modules.city.repository import city_repo
def _resp_body(resp):
return json.loads(resp.body)
def test_get_city_list_have_child_is_bool(fake_request, sqlite_db):
city_repo.create(
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
level=1, parent_id=0, name="北京市", short_name="北京", full_name="北京市",
pinyin="beijing", lng="116.4", lat="39.9",
)
fake_request.query_params = {"parent_id": 0}
resp = city_service.get_city_list(fake_request)
data = _resp_body(resp)["data"]
assert isinstance(data, list) and len(data) == 1
assert data[0]["haveChild"] is True
def test_get_city_list_leaf_have_child_false(fake_request, sqlite_db):
city_repo.create(
city_code="100000", area_code="100000", parent_code="0", zip_code="000000",
level=4, parent_id=0, name="某街道", short_name="街道", full_name="某街道",
pinyin="jiedao", lng="116.4", lat="39.9",
)
fake_request.query_params = {"parent_id": 0}
resp = city_service.get_city_list(fake_request)
data = _resp_body(resp)["data"]
assert isinstance(data, list) and len(data) == 1
assert data[0]["haveChild"] is False
+198
View File
@@ -0,0 +1,198 @@
"""删除级联防护:存在外键引用时禁止删除(dept→user、category→article、city→子级、dict/config→item)。"""
import json
import pytest
from core import R
from core.base_service import BaseService
from utils.request import parse_id_list
from modules.system.dept.repository import dept_repo
from modules.system.dept.service import delete_dept
from modules.system.role.models import Role, RoleMenu
from modules.system.user.repository import user_repo
from modules.category.repository import category_repo
from modules.category.service import delete_category
from modules.article.repository import article_repo
from modules.city.repository import city_repo
from modules.city.service import delete_city
from modules.dictionary.dict_item.repository import dictitem_repo
from modules.dictionary.dict.service import dict_service
from modules.configuration.config_item.repository import configitem_repo
from modules.configuration.config.service import config_service
class _FakeQuery:
"""模拟 SQLAlchemy Query:仅支持 first(),返回行或 None。"""
def __init__(self, has_row):
self._has_row = has_row
def first(self):
return object() if self._has_row else None
def _body(result):
return json.loads(result.body)
def _allow_delete(monkeypatch):
"""放行批量删除,捕获传入的 ids 以便断言走到了该步骤。"""
captured = {}
def fake_batch_delete(repo, ids):
captured['ids'] = ids
return R.ok(msg="本次共删除{0}条数据".format(len(parse_id_list(str(ids)))))
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
return captured
# ============ dept → user ============
def test_delete_dept_blocks_when_users_reference(monkeypatch):
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级部门
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有用户引用
result = delete_dept('1')
assert _body(result)['ok'] is False
assert '用户' in _body(result)['msg']
def test_delete_dept_allows_when_no_references(monkeypatch):
monkeypatch.setattr(dept_repo, 'filter', lambda *a, **k: _FakeQuery(False))
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
captured = _allow_delete(monkeypatch)
result = delete_dept('1,2')
assert _body(result)['ok'] is True
assert captured['ids'] == '1,2'
# ============ category → article ============
def test_delete_category_blocks_when_articles_reference(monkeypatch):
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False)) # 无下级分类
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(True)) # 有文章引用
result = delete_category('1')
assert _body(result)['ok'] is False
assert '文章' in _body(result)['msg']
def test_delete_category_allows_when_no_references(monkeypatch):
monkeypatch.setattr(category_repo, 'filter', lambda *a, **k: _FakeQuery(False))
monkeypatch.setattr(article_repo, 'filter', lambda *a, **k: _FakeQuery(False))
captured = _allow_delete(monkeypatch)
result = delete_category('1,2')
assert _body(result)['ok'] is True
assert captured['ids'] == '1,2'
# ============ city → 子级 ============
def test_delete_city_blocks_when_child_exists(monkeypatch):
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(True))
result = delete_city('1')
assert _body(result)['ok'] is False
assert '下级' in _body(result)['msg']
def test_delete_city_allows_when_no_children(monkeypatch):
monkeypatch.setattr(city_repo, 'filter', lambda *a, **k: _FakeQuery(False))
captured = _allow_delete(monkeypatch)
result = delete_city('1,2')
assert _body(result)['ok'] is True
assert captured['ids'] == '1,2'
# ============ dict → dict_item ============
def test_dict_before_delete_blocks_when_items_exist(monkeypatch):
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
err = dict_service._before_delete('1,2')
assert err is not None
assert '字典项' in err
def test_dict_before_delete_allows_when_no_items(monkeypatch):
monkeypatch.setattr(dictitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
assert dict_service._before_delete('1,2') is None
# ============ config → config_item ============
def test_config_before_delete_blocks_when_items_exist(monkeypatch):
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(True))
err = config_service._before_delete('1,2')
assert err is not None
assert '配置项' in err
def test_config_before_delete_allows_when_no_items(monkeypatch):
monkeypatch.setattr(configitem_repo, 'filter', lambda *a, **k: _FakeQuery(False))
assert config_service._before_delete('1,2') is None
# ============ role → user_role / role_menu 残留清理 ============
@pytest.mark.asyncio
async def test_delete_role_cleans_user_role_and_role_menu(sqlite_db, monkeypatch):
from modules.system.role import service as role_service
from modules.system.role.models import Role
from modules.system.role_menu.models import RoleMenu
from modules.system.user_role.models import UserRole
from core.database import get_db_session
async def _noop(*args, **kwargs):
return None
monkeypatch.setattr(role_service, 'invalidate_role_perms', _noop)
# 显式非内置 id(内置角色 1-4 禁止删除)
role = Role(id=100, name='普通角色', code='R1', status=1, sort=1)
role.save()
db = get_db_session()
db.add_all([
UserRole(user_id=5, role_id=role.id),
RoleMenu(role_id=role.id, menu_id=10),
])
db.commit()
resp = await role_service.delete_role(str(role.id))
assert _body(resp)['code'] == 0
# 关联残留已清理,角色本身软删
assert db.query(UserRole).filter(UserRole.role_id == role.id).count() == 0
assert db.query(RoleMenu).filter(RoleMenu.role_id == role.id).count() == 0
assert role.is_delete == 1
# ============ position / level → user 引用(与部门一致) ============
def test_position_before_delete_blocks_when_users_reference(monkeypatch):
from modules.system.position.service import position_service
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
err = position_service._before_delete('1')
assert err is not None
assert '岗位' in err
def test_position_before_delete_allows_when_no_references(monkeypatch):
from modules.system.position.service import position_service
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
assert position_service._before_delete('1,2') is None
def test_level_before_delete_blocks_when_users_reference(monkeypatch):
from modules.system.level.service import level_service
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(True))
err = level_service._before_delete('1')
assert err is not None
assert '职级' in err
def test_level_before_delete_allows_when_no_references(monkeypatch):
from modules.system.level.service import level_service
monkeypatch.setattr(user_repo, 'filter', lambda *a, **k: _FakeQuery(False))
assert level_service._before_delete('1,2') is None
+42
View File
@@ -0,0 +1,42 @@
"""system/dept service 单元测试(回归删除父级时禁止有子级)。"""
import json
from modules.system.dept import service as dept_service
from modules.system.dept.models import Dept
from modules.system.dept.repository import dept_repo
from modules.system.dept.schemas import DeptForm
def _resp_body(resp):
return json.loads(resp.body)
def _form(**overrides):
data = dict(name="总公司", code="HQ", type=1, parentId=None, sort=1, note=None)
data.update(overrides)
return DeptForm(**data)
def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
dept_service.add_dept(fake_request, _form())
parent = dept_repo.filter(Dept.name == "总公司").first()
dept_service.add_dept(
fake_request,
_form(name="研发部", code="RD", parentId=parent.id),
)
child = dept_repo.filter(Dept.name == "研发部").first()
resp = dept_service.delete_dept(str(parent.id))
assert _resp_body(resp)["code"] != 0
assert dept_repo.get_by_id(parent.id).is_delete == 0
assert dept_repo.get_by_id(child.id).is_delete == 0
def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
dept_service.add_dept(fake_request, _form())
leaf = dept_repo.filter(Dept.name == "总公司").first()
resp = dept_service.delete_dept(str(leaf.id))
assert _resp_body(resp)["code"] == 0
assert dept_repo.filter(Dept.id == leaf.id).first().is_delete == 1
+101
View File
@@ -0,0 +1,101 @@
"""字典项唯一性按所属字典隔离:同一字典下名称不可重复,不同字典同名放行。"""
import json
from modules.dictionary.dict_item.repository import dictitem_repo
from modules.dictionary.dict_item.schemas import DictItemForm
from modules.dictionary.dict_item.service import dict_item_service
class _FakeQuery:
"""模拟 SQLAlchemy Query:记录是否调用 filter(排除自身),first() 返回行或 None。"""
def __init__(self, has_row=True):
self._has_row = has_row
self.filter_called = False
def filter(self, *criteria):
self.filter_called = True
# 排除自身后应无重复行
return _FakeQuery(False)
def first(self):
return object() if self._has_row else None
def _body(result):
return json.loads(result.body)
def _set_filter_by(monkeypatch, query):
monkeypatch.setattr(dictitem_repo, 'filter_by', lambda **kw: query)
# ============ 查重范围:按 (dict_id, name) 隔离 ============
def test_dict_item_unique_scoped_to_dict(monkeypatch):
captured = {}
def fake_filter_by(**kw):
captured.update(kw)
return _FakeQuery(False)
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
assert dict_item_service._check_unique(data) is None
# 查重条件是 dict_id + name,而非仅 name 全局查重
assert captured == {'dict_id': 2, 'name': '启用'}
def test_dict_item_unique_blocks_same_name_in_same_dict(monkeypatch):
captured = {}
def fake_filter_by(**kw):
captured.update(kw)
return _FakeQuery(True)
monkeypatch.setattr(dictitem_repo, 'filter_by', fake_filter_by)
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
assert captured == {'dict_id': 2, 'name': '启用'}
# ============ 编辑时排除自身 ============
def test_dict_item_unique_excludes_self_on_update(monkeypatch):
query = _FakeQuery(True)
_set_filter_by(monkeypatch, query)
data = DictItemForm(id=5, name='启用', value='1', dictId=2, sort=1)
# 排除自身后无重复 → 放行
assert dict_item_service._check_unique(data, exclude_id=5) is None
assert query.filter_called is True
def test_dict_item_unique_add_does_not_exclude(monkeypatch):
query = _FakeQuery(True)
_set_filter_by(monkeypatch, query)
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
# 新增不排除自身 → 查到同名字段即判重
assert dict_item_service._check_unique(data) == '字典项名称不能重复'
assert query.filter_called is False
# ============ add 端到端:不同字典同名可新增,同字典同名被拒 ============
def test_dict_item_add_allows_same_name_in_different_dict(monkeypatch, fake_request):
_set_filter_by(monkeypatch, _FakeQuery(False))
captured = {}
monkeypatch.setattr(dictitem_repo, 'create', lambda **fields: captured.update(fields) or object())
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
result = dict_item_service.add(fake_request, data)
assert _body(result)['ok'] is True
assert captured['dictId'] == 2
def test_dict_item_add_blocks_same_name_in_same_dict(monkeypatch, fake_request):
_set_filter_by(monkeypatch, _FakeQuery(True))
data = DictItemForm(name='启用', value='1', dictId=2, sort=1)
result = dict_item_service.add(fake_request, data)
assert _body(result)['ok'] is False
assert '重复' in _body(result)['msg']
+102
View File
@@ -0,0 +1,102 @@
"""dict_util 字典读取工具:列表/映射/单值读取,进程内缓存与失效行为。"""
from types import SimpleNamespace
from utils import dict_util
from utils.dict_util import (
get_dict_items,
get_dict_label,
get_dict_map,
invalidate_dict,
invalidate_dicts_by_ids,
)
def _clear_cache():
dict_util._dict_cache.clear()
def _rows():
# 刻意乱序:女(sort=5)、男(sort=1)、保密(sort=10),校验按 sort+id 升序输出
return [
SimpleNamespace(id=2, name='女', value='2', sort=5),
SimpleNamespace(id=1, name='男', value='1', sort=1),
SimpleNamespace(id=3, name='保密', value='3', sort=10),
]
class _FakeQuery:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
def _patch_repo(monkeypatch, dict_obj):
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: dict_obj)
monkeypatch.setattr(dict_util.dictitem_repo, 'filter_by', lambda **kw: _FakeQuery(_rows()))
# ============ 读取 ============
def test_get_dict_map_maps_value_to_label(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
assert get_dict_map('gender') == {'1': '男', '2': '女', '3': '保密'}
def test_get_dict_items_sorted_by_sort_then_id(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
assert [it['value'] for it in get_dict_items('gender')] == ['1', '2', '3']
def test_get_dict_label_normalizes_value_to_str(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
# 用户存整数 1,字典项 value 是字符串 '1',需自动转字符串匹配
assert get_dict_label('gender', 1) == '男'
assert get_dict_label('gender', '2') == '女'
assert get_dict_label('gender', 99) is None
assert get_dict_label('gender', 99, '未知') == '未知'
def test_get_dict_map_unknown_code_returns_empty(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, None)
assert get_dict_items('nope') == []
assert get_dict_map('nope') == {}
# ============ 缓存 ============
def test_cache_hit_skips_db(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
get_dict_map('gender')
calls = []
monkeypatch.setattr(dict_util.dict_repo, 'get_one', lambda **kw: calls.append(1) or None)
get_dict_map('gender') # 命中缓存,不再访问 DB
assert calls == []
def test_invalidate_dict_clears_cache(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
get_dict_map('gender')
invalidate_dict('gender')
assert dict_util._dict_cache == {}
def test_invalidate_dicts_by_ids_clears_cached_codes(monkeypatch):
_clear_cache()
_patch_repo(monkeypatch, SimpleNamespace(id=1, code='gender', name='性别'))
get_dict_map('gender') # 先填充缓存
class _FakeRows:
def all(self):
return [SimpleNamespace(code='gender')]
monkeypatch.setattr(dict_util.dict_repo, 'filter', lambda *criteria: _FakeRows())
invalidate_dicts_by_ids({1})
assert dict_util._dict_cache == {}
+301
View File
@@ -0,0 +1,301 @@
"""调度器安全相关测试:SSRF 防护 + 单实例防护 + 统一线程池。"""
from modules.job import scheduler as sch
def test_build_full_url():
assert sch.build_full_url('/api/x') == f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/x"
assert sch.build_full_url('http://a.com/x') == 'http://a.com/x'
assert sch.build_full_url('ftp://a.com/x') == 'ftp://a.com/x'
def test_validate_http_url_rejects_internal_ips():
for url in (
'http://127.0.0.1:6379/', # 回环
'http://10.0.0.1/x', # 私有A类
'http://169.254.169.254/latest', # 云元数据
'http://192.168.1.1/x', # 私有C类
'http://172.16.5.5/x', # 私有B类
):
ok, reason = sch._validate_http_url(url)
assert ok is False, url
assert '禁止' in reason or '内网' in reason
def test_validate_http_url_rejects_non_http_scheme():
for url in ('ftp://a.com/x', 'file:///etc/passwd', 'javascript:alert(1)'):
ok, reason = sch._validate_http_url(url)
assert ok is False, url
assert '协议' in reason
def test_validate_http_url_accepts_public_ip():
# 公网 IP 字面量,无需真实 DNS
ok, reason = sch._validate_http_url('http://93.184.216.34/')
assert ok is True, reason
def test_execute_by_url_blocks_internal_url(monkeypatch):
sent = []
monkeypatch.setattr(
sch, '_send_sync_request',
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
result = sch._execute_by_url(1, 'http://127.0.0.1:6379', 1)
assert result['success'] is False
assert '禁止' in result['error'] or '内网' in result['error']
assert sent == [] # 未实际发起请求
def test_execute_by_url_relative_self_call_allowed(monkeypatch):
sent = []
monkeypatch.setattr(
sch, '_send_sync_request',
lambda url, data, jid, pinned_ip=None: sent.append(url) or {'success': True})
result = sch._execute_by_url(1, '/api/ping', 1)
assert result['success'] is True
assert sent == [f"http://{sch.FASTAPI_HOST}:{sch.FASTAPI_PORT}/api/ping"]
# ============================================================
# SSRF DNS 重绑定防护:预解析后固定 IP 直连,杜绝二次解析域名
# ============================================================
def test_resolve_and_pin_returns_pinned_ip():
# 公网 IP 字面量,无需真实 DNS
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34/x')
assert ok is True, reason
assert ip == '93.184.216.34'
def test_resolve_and_pin_rejects_custom_port():
ok, reason, ip = sch._resolve_and_pin('http://93.184.216.34:8080/x')
assert ok is False
assert '端口' in reason
assert ip is None
def test_resolve_and_pin_allows_80_443():
for url in ('http://93.184.216.34:80/x', 'https://93.184.216.34:443/x'):
ok, reason, ip = sch._resolve_and_pin(url)
assert ok is True, reason
assert ip == '93.184.216.34'
def test_pinned_connection_pins_dns_host_keeps_real_host():
# HTTP 与 HTTPS 连接:_dns_host 固定为预校验 IP(实际建连目标),
# host 保持真实主机名(Host 头 / TLS SNI)——同域名只解析一次,杜绝重绑定。
for cls, port in ((sch._PinnedHTTPConnection, 80), (sch._PinnedHTTPSConnection, 443)):
conn = cls(host='example.com', port=port, pinned_ip='93.184.216.34')
assert conn._dns_host == '93.184.216.34'
assert conn.host == 'example.com'
def test_send_sync_request_uses_pinned_ip_when_given(monkeypatch):
calls = []
class _FakeResp:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {'ok': 1}
monkeypatch.setattr(
sch, '_post_pinned',
lambda url, data, headers, timeout, pinned_ip: calls.append((url, pinned_ip)) or _FakeResp())
result = sch._send_sync_request('http://example.com/x', {'a': 1}, 1, '93.184.216.34')
assert result['success'] is True
assert calls == [('http://example.com/x', '93.184.216.34')]
def test_send_sync_request_without_pinned_ip_uses_plain_post(monkeypatch):
calls = []
class _FakeResp:
status_code = 200
def raise_for_status(self):
pass
def json(self):
return {'ok': 1}
monkeypatch.setattr(
sch, '_post_pinned',
lambda *a, **k: calls.append('pinned') or _FakeResp())
monkeypatch.setattr(
sch.requests, 'post',
lambda url, **kw: calls.append('plain') or _FakeResp())
# 相对路径自调用等可信场景无固定 IP,应走常规 requests.post 而非 pinned 路径
result = sch._send_sync_request('http://127.0.0.1:8031/api/ping', {'a': 1}, 1)
assert result['success'] is True
assert calls == ['plain']
def test_job_single_instance_claim(monkeypatch):
# 禁用 Redis 锁(退化进程内互斥),使进程内 set 行为可独立验证
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
assert sch._try_claim_job(42) is True
assert sch._try_claim_job(42) is False # 已占用,拒绝重入
sch._release_job(42)
assert sch._try_claim_job(42) is True
sch._release_job(42)
# ============================================================
# Redis 分布式锁:跨 worker 进程单实例互斥
# ============================================================
def test_try_claim_job_respects_cross_process_redis_lock(monkeypatch):
import fakeredis
fake = fakeredis.FakeRedis()
monkeypatch.setattr(sch, '_get_lock_redis', lambda: fake)
# 进程内重复触发仍被本地 set 拦截
assert sch._try_claim_job(7) is True
assert sch._try_claim_job(7) is False
sch._release_job(7)
assert sch._try_claim_job(7) is True
sch._release_job(7)
# 模拟他进程已持锁:Redis 锁被占用 → 本进程拒绝,且本地占用已回滚
fake.set(f'{sch._JOB_LOCK_PREFIX}9', '1', nx=True, ex=300)
assert sch._try_claim_job(9) is False
assert 9 not in sch._running_jobs
# 释放后(他进程删除锁)可再次占用
fake.delete(f'{sch._JOB_LOCK_PREFIX}9')
assert sch._try_claim_job(9) is True
sch._release_job(9)
def test_try_claim_job_degrades_when_redis_unavailable(monkeypatch):
# Redis 不可用 → 退化进程内互斥,不中断任务执行
monkeypatch.setattr(sch, '_get_lock_redis', lambda: None)
assert sch._try_claim_job(10) is True
assert sch._try_claim_job(10) is False # 本地 set 仍生效
sch._release_job(10)
# ============================================================
# _handle_scheduler_update:运行中任务编辑后必须重加调度器刷新执行参数
# ============================================================
def _patch_scheduler_calls(monkeypatch):
"""打桩 job.service 中的调度器函数,记录 add/remove 调用。"""
from modules.job import service as job_svc
calls = {'add': [], 'remove': []}
monkeypatch.setattr(
job_svc, 'add_job_to_scheduler',
lambda data: calls['add'].append(data) or True)
monkeypatch.setattr(
job_svc, 'remove_job_from_scheduler',
lambda jid: calls['remove'].append(jid) or True)
return job_svc, calls
def test_handle_scheduler_update_running_edit_readds(monkeypatch):
"""运行中任务编辑 url/params:cron 未变也需重加,否则调度器仍按旧参数执行。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'url': '/api/new'}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['remove'] == []
assert len(calls['add']) == 1
# 重加时携带完整任务状态与有效 cron(部分更新缺 cron 时沿用旧值)
added = calls['add'][0]
assert added['id'] == 5
assert added['url'] == '/api/new'
assert added['cron_expression'] == '0 * * * *'
def test_handle_scheduler_update_running_explicit_new_cron(monkeypatch):
"""运行中任务显式改 cron:以新 cron 重加。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'cron_expression': '*/5 * * * *'}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['add'][0]['cron_expression'] == '*/5 * * * *'
def test_handle_scheduler_update_running_to_disabled_removes(monkeypatch):
"""运行中 → 禁用:从调度器移除。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 2}, old_status=1, old_cron='0 * * * *')
assert ok is True
assert calls['add'] == []
assert calls['remove'] == [5]
def test_handle_scheduler_update_disabled_to_running_readds(monkeypatch):
"""暂停 → 启用且带 cron:重加。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1, 'cron_expression': '0 * * * *'}, old_status=2, old_cron='0 * * * *')
assert ok is True
assert len(calls['add']) == 1
assert calls['remove'] == []
def test_handle_scheduler_update_disabled_to_running_without_cron(monkeypatch):
"""暂停 → 启用但无 cron:无法调度,移除(保持调度器一致性)。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 1}, old_status=2, old_cron=None)
assert ok is True
assert calls['add'] == []
assert calls['remove'] == [5]
def test_handle_scheduler_update_no_status_change_noop(monkeypatch):
"""保持暂停:不触发任何调度器操作。"""
job_svc, calls = _patch_scheduler_calls(monkeypatch)
ok = job_svc._handle_scheduler_update(
{'id': 5, 'status': 2}, old_status=2, old_cron=None)
assert ok is True
assert calls['add'] == []
assert calls['remove'] == []
# ============================================================
# _build_cron_trigger:5/6 字段 cron,6 字段首字段为秒(秒级触发)
# ============================================================
def _fire_seconds_away(cron_expr):
"""返回 (next_fire, 距 now 的秒数)。now 取本地 aware 时间,与触发器默认时区对齐。"""
from datetime import datetime
now = datetime.now().astimezone()
nf = sch._build_cron_trigger(cron_expr).get_next_fire_time(None, now)
return nf, (nf - now).total_seconds() if nf else None
def test_build_cron_trigger_5_fields_minute_granularity():
nf, delta = _fire_seconds_away('0 * * * *') # 每小时整点
assert nf is not None
assert 0 < delta <= 3600
def test_build_cron_trigger_6_fields_second_granularity():
nf, delta = _fire_seconds_away('*/15 * * * * *') # 每 15 秒
assert nf is not None
assert 0 < delta <= 15, f"秒级 cron 应 15s 内触发,实际 {delta}s"
def test_build_cron_trigger_5_field_matches_from_crontab():
"""5 字段行为与 CronTrigger.from_crontab 完全一致(向后兼容)。"""
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime
now = datetime.now().astimezone()
t1 = sch._build_cron_trigger('0 * * * *').get_next_fire_time(None, now)
t2 = CronTrigger.from_crontab('0 * * * *').get_next_fire_time(None, now)
assert t1 == t2
def test_build_cron_trigger_invalid_field_count_raises():
import pytest
for bad in ('', '* * * *', '* * * * * * *'): # 0/4/7 字段均非法
with pytest.raises(ValueError):
sch._build_cron_trigger(bad)
+45
View File
@@ -0,0 +1,45 @@
"""modules/job/service.py 暂停/恢复任务:update_user 应取当前操作人,而非创建人。"""
import json
import types
from modules.job import service as job_service
from modules.job.models import Job
def _make_request(user_id=5, realname='测试用户'):
req = types.SimpleNamespace()
req.state = types.SimpleNamespace(user_id=user_id, realname=realname)
return req
def _make_job(sqlite_db, status=1, create_user=1):
job = Job(job_name='任务A', status=status, cron_expression='0 * * * *',
execute_policy=1, is_sync=0, create_user=create_user)
job.save()
sqlite_db.commit()
return job
def _body(resp):
return json.loads(resp.body)
def test_pause_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
monkeypatch.setattr(job_service, 'pause_job_in_scheduler', lambda jid: True)
job = _make_job(sqlite_db, status=1, create_user=1)
resp = job_service.pause_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
assert _body(resp)['code'] == 0
assert job.status == 2
# 更新人 = 操作人姓名(张三),而非创建人(1)
assert job.update_user == '张三'
def test_resume_job_sets_update_user_to_operator(monkeypatch, sqlite_db):
monkeypatch.setattr(job_service, 'add_job_to_scheduler', lambda data: True)
job = _make_job(sqlite_db, status=2, create_user=1)
resp = job_service.resume_job_by_id(_make_request(user_id=5, realname='张三'), job.id)
assert _body(resp)['code'] == 0
assert job.status == 1
assert job.update_user == '张三'
+163
View File
@@ -0,0 +1,163 @@
"""system/menu service 单元测试(async 路径,回归删除父级时禁止有子级)。
测试环境无 Redis:monkeypatch 掉 invalidate_menu_change,避免 get_redis() 抛异常。
"""
import json
import pytest
from modules.system.menu import service as menu_service
from modules.system.menu.models import Menu
from modules.system.menu.repository import menu_repo
from modules.system.menu.schemas import MenuForm
@pytest.fixture(autouse=True)
def _stub_perm_cache(monkeypatch):
async def _noop():
return None
monkeypatch.setattr(menu_service, "invalidate_menu_change", _noop)
def _resp_body(resp):
return json.loads(resp.body)
def _form(**overrides):
data = dict(
name="系统管理", icon=None, path="/system", component=None, parentId=0,
type=0, target=0, permission=None, hide=0, status=1, sort=1, note=None,
)
data.update(overrides)
return MenuForm(**data)
@pytest.mark.asyncio
async def test_delete_parent_with_child_blocked(fake_request, sqlite_db):
await menu_service.add_menu(fake_request, _form())
parent = menu_repo.filter(Menu.name == "系统管理").first()
await menu_service.add_menu(
fake_request,
_form(name="用户管理", path="/system/user", parentId=parent.id),
)
child = menu_repo.filter(Menu.name == "用户管理").first()
resp = await menu_service.delete_menu(str(parent.id))
body = _resp_body(resp)
assert body["code"] != 0
assert "下级菜单" in body["msg"]
assert menu_repo.get_by_id(parent.id).is_delete == 0
assert menu_repo.get_by_id(child.id).is_delete == 0
@pytest.mark.asyncio
async def test_delete_leaf_soft_deletes(fake_request, sqlite_db):
await menu_service.add_menu(fake_request, _form(name="单菜单", path="/single"))
leaf = menu_repo.filter(Menu.name == "单菜单").first()
resp = await menu_service.delete_menu(str(leaf.id))
assert _resp_body(resp)["code"] == 0
assert menu_repo.filter(Menu.id == leaf.id).first().is_delete == 1
# ============ 菜单树成环检测:update 的 parent_id 存在性 + 自身/下级 校验 ============
@pytest.mark.asyncio
async def test_update_menu_rejects_missing_parent(fake_request, sqlite_db):
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
menu = menu_repo.filter(Menu.name == "系统管理").first()
resp = await menu_service.update_menu(
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=99999))
body = _resp_body(resp)
assert body["code"] != 0
assert "上级菜单不存在" in body["msg"]
@pytest.mark.asyncio
async def test_update_menu_rejects_self_parent(fake_request, sqlite_db):
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
menu = menu_repo.filter(Menu.name == "系统管理").first()
resp = await menu_service.update_menu(
fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=menu.id))
body = _resp_body(resp)
assert body["code"] != 0
assert "自身" in body["msg"]
# 原记录未被改动
assert menu_repo.get_by_id(menu.id).parent_id == 0
@pytest.mark.asyncio
async def test_update_menu_rejects_descendant_parent(fake_request, sqlite_db):
await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system"))
parent = menu_repo.filter(Menu.name == "系统管理").first()
await menu_service.add_menu(
fake_request, _form(name="用户管理", path="/system/user", parentId=parent.id))
child = menu_repo.filter(Menu.name == "用户管理").first()
# 把父级设为自己的子级 → 成环,拒绝
resp = await menu_service.update_menu(
fake_request, _form(name="系统管理", path="/system", id=parent.id, parentId=child.id))
body = _resp_body(resp)
assert body["code"] != 0
assert "下级" in body["msg"]
assert menu_repo.get_by_id(parent.id).parent_id == 0
# ============ 多角色重复授权:权限列表 / 菜单树 distinct 去重 ============
def _seed_user_with_two_roles_same_menu(sqlite_db):
"""造一个同时属于两个角色、且两角色均授予同一权限节点/菜单的用户。
返回的用户 id 必不为 1(先占位超管),否则会走超级管理员全量放行分支。
"""
from modules.system.role.models import Role
from modules.system.role_menu.models import RoleMenu
from modules.system.user.models import User
from modules.system.user_role.models import UserRole
User(realname='超管', nickname='admin', avatar='', mobile='', email='',
province_code='', city_code='', district_code='', street_code='', address='',
username='admin', password='', status=1).save()
user = User(realname='张三', nickname='zhangsan', avatar='', mobile='', email='',
province_code='', city_code='', district_code='', street_code='', address='',
username='zhangsan', password='', status=1)
user.save()
r1 = Role(name='角色一', code='R1', status=1, sort=1); r1.save()
r2 = Role(name='角色二', code='R2', status=1, sort=1); r2.save()
perm = Menu(name='添加用户', parent_id=0, path='/system/user/add', type=1,
permission='sys:user:add', status=0, sort=1)
perm.save()
menu = Menu(name='用户管理', parent_id=0, path='/system/user', component='system/user/index',
type=0, status=0, hide=0, sort=1)
menu.save()
# 两个角色授予同一权限节点与同一菜单 → 多角色 join 会产生重复行
for role in (r1, r2):
RoleMenu(role_id=role.id, menu_id=perm.id).save()
RoleMenu(role_id=role.id, menu_id=menu.id).save()
UserRole(user_id=user.id, role_id=role.id).save()
sqlite_db.commit()
return user
def test_permissions_list_dedup_for_multi_role(sqlite_db):
user = _seed_user_with_two_roles_same_menu(sqlite_db)
perms = menu_service.get_permissions_list_sync(user.id)
# 同一权限节点经两个角色授权只出现一次
assert perms.count('sys:user:add') == 1
assert len(perms) == len(set(perms))
def test_menu_list_dedup_for_multi_role(sqlite_db):
user = _seed_user_with_two_roles_same_menu(sqlite_db)
tree = menu_service.get_permission_menu_list_sync(user.id)
# 菜单树无重复节点
assert len(tree) == 1
assert tree[0]['name'] == '用户管理'
+44
View File
@@ -0,0 +1,44 @@
"""notice service 单元测试(回归富文本 content 空值保护)。"""
import json
from modules.notice.service import notice_service
from modules.notice.models import Notice
from modules.notice.repository import notice_repo
from modules.notice.schemas import NoticeForm
def _resp_body(resp):
return json.loads(resp.body)
def _form(**overrides):
data = dict(title="测试通知", source=1, url="https://example.com", status=1, isTop=1, click=0)
data.update(overrides)
return NoticeForm(**data)
def test_add_notice_content_none_saves_null(fake_request, sqlite_db):
resp = notice_service.add(fake_request, _form(content=None))
assert _resp_body(resp)["code"] == 0
row = notice_repo.filter(Notice.title == "测试通知").first()
assert row is not None
assert row.content is None
def test_add_notice_plain_text_stored_as_is(fake_request, sqlite_db):
resp = notice_service.add(fake_request, _form(title="纯文本通知", content="hello world"))
assert _resp_body(resp)["code"] == 0
row = notice_repo.filter(Notice.title == "纯文本通知").first()
assert row.content == "hello world"
def test_update_notice_content_none_keeps_old(fake_request, sqlite_db):
notice_service.add(fake_request, _form(title="保留旧内容", content="old-content"))
row = notice_repo.filter(Notice.title == "保留旧内容").first()
resp = notice_service.update(
fake_request,
_form(title="保留旧内容", content=None, id=row.id),
)
assert _resp_body(resp)["code"] == 0
assert notice_repo.get_by_id(row.id).content == "old-content"
+26 -2
View File
@@ -1,5 +1,6 @@
"""shared/base_repository 与模块 repository 的单元测试(SQLite 内存库)。"""
from modules.system.level.models import Level
from modules.system.level.repository import level_repo
from modules.system.position.repository import position_repo
@@ -25,8 +26,8 @@ def test_level_repo_crud(sqlite_db):
assert level_repo.update(obj.id, {"name": "资深"}) == 1
assert level_repo.get_by_id(obj.id).name == "资深"
# soft delete
assert level_repo.soft_delete([obj.id])
# batch soft delete(数据原语,返回删除条数)
assert level_repo.batch_delete(str(obj.id)) == 1
assert level_repo.get_by_id(obj.id) is None
@@ -34,3 +35,26 @@ def test_position_repo_paginate_empty(sqlite_db):
items, total = position_repo.paginate(1, 10)
assert total == 0
assert items == []
def test_repo_query_conveniences(sqlite_db):
level_repo.create(name="甲", status=1, sort=1)
level_repo.create(name="乙", status=1, sort=2)
level_repo.create(name="丙", status=2, sort=3)
# exists_by_field / 排除自身
assert level_repo.exists_by_field("name", "甲")
assert not level_repo.exists_by_field("name", "不存在")
target = level_repo.get_one(name="甲")
assert not level_repo.exists_by_field("name", "甲", exclude_id=target.id)
# get_one / get_all / count / filter_by(None 值跳过 + 自动软删过滤)
assert level_repo.get_one(name="乙").sort == 2
assert len(level_repo.get_all(status=1)) == 2
assert level_repo.count(status=1) == 2
rows = level_repo.filter_by(status=1).order_by(Level.sort.asc()).all()
assert [row.name for row in rows] == ["甲", "乙"]
# 软删后 count 自动排除
level_repo.batch_delete(str(target.id))
assert level_repo.count() == 2
@@ -0,0 +1,69 @@
"""schema 校验回归:mobile/email 格式 + click/sort 上限(防 INT 溢出 500)。"""
from datetime import date
import pytest
from pydantic import ValidationError
from modules.system.user.schemas import UserForm, UserInfoForm
from modules.article.schemas import ArticleForm
from modules.notice.schemas import NoticeForm
from modules.param.schemas import ParamForm
from modules.file_template.schemas import FileTemplateForm
def _base_user():
return dict(
realname='张三', nickname='张三', gender=1, avatar='a.png',
mobile='13800000000', email='a@b.com', birthday=date(2000, 1, 1),
deptId=1, levelId=1, positionId=1,
city=['110000', '110100', '110101'], address='北京市',
username='zhangsan', password='123456', status=1, sort=1, roles=[1, 2],
)
def test_user_mobile_accepts_valid_and_rejects_bad():
UserForm(**{**_base_user(), 'mobile': '13800000000'})
for bad in ('abc', '12345', '23800000000', '1380000000', '138000000001'):
with pytest.raises(ValidationError):
UserForm(**{**_base_user(), 'mobile': bad})
def test_user_email_accepts_valid_and_rejects_bad():
UserForm(**{**_base_user(), 'email': 'a@b.com'})
for bad in ('not-an-email', 'a@b', '@b.com', 'a b@c.com'):
with pytest.raises(ValidationError):
UserForm(**{**_base_user(), 'email': bad})
def test_user_info_form_mobile_email_format():
# 修改个人信息表单同样校验格式
base = dict(realname='张三', nickname='zs', gender=1, address='x')
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'a@b.com'})
with pytest.raises(ValidationError):
UserInfoForm(**{**base, 'mobile': 'abc', 'email': 'a@b.com'})
with pytest.raises(ValidationError):
UserInfoForm(**{**base, 'mobile': '13800000000', 'email': 'bad'})
def test_article_click_overflow_rejected():
ArticleForm(title='t', click=2147483647, sort=1)
with pytest.raises(ValidationError):
ArticleForm(title='t', click=2147483648, sort=1)
def test_notice_click_overflow_rejected():
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483647)
with pytest.raises(ValidationError):
NoticeForm(title='t', source=1, url='u', status=1, isTop=1, click=2147483648)
def test_param_sort_overflow_rejected():
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=99999)
with pytest.raises(ValidationError):
ParamForm(name='n', code='c', value='v', type=0, status=1, sort=100000)
def test_file_template_sort_overflow_rejected():
FileTemplateForm(sort=99999)
with pytest.raises(ValidationError):
FileTemplateForm(sort=100000)
@@ -0,0 +1,115 @@
"""超管账号与内置角色写保护:user id==1 与内置角色(种子数据)禁止被管理写操作影响。"""
import asyncio
import json
import types
from core import R
from core.base_service import BaseService
import modules.system.user.service as user_service
import modules.system.role.service as role_service
from modules.system.role.repository import role_repo
def _body(result):
return json.loads(result.body)
# ============ 超管账号(user id==1)写保护 ============
def test_delete_user_blocks_superadmin():
result = asyncio.run(user_service.delete_user('1'))
assert _body(result)['ok'] is False
assert '超管' in _body(result)['msg']
def test_delete_user_allows_normal_user(monkeypatch):
# 屏蔽会话与批量删除,仅验证非超管不会被误拦
class _FakeDelete:
def delete(self, *a, **k):
return 1
class _FakeQuery:
def filter(self, *a, **k):
return _FakeDelete()
monkeypatch.setattr(user_service, 'get_db_session',
lambda: types.SimpleNamespace(query=lambda m: _FakeQuery()))
captured = {}
def fake_batch_delete(repo, ids):
captured['ids'] = ids
return R.ok(msg="本次共删除{0}条数据".format(1))
monkeypatch.setattr(BaseService, 'batch_delete_with_r', staticmethod(fake_batch_delete))
async def fake_invalidate(uid):
pass
monkeypatch.setattr(user_service, 'invalidate_user_perms', fake_invalidate)
result = asyncio.run(user_service.delete_user('2'))
assert _body(result)['ok'] is True
assert captured['ids'] == '2'
def test_update_user_blocks_superadmin(fake_request):
result = asyncio.run(user_service.update_user(fake_request, types.SimpleNamespace(id=1)))
assert _body(result)['ok'] is False
assert '超管' in _body(result)['msg']
def test_update_user_status_blocks_superadmin(fake_request):
result = asyncio.run(
user_service.update_user_status(fake_request, types.SimpleNamespace(id=1, status=2)))
assert _body(result)['ok'] is False
assert '超管' in _body(result)['msg']
def test_reset_user_pwd_blocks_superadmin(fake_request):
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=1))
assert _body(result)['ok'] is False
assert '超管' in _body(result)['msg']
def test_reset_user_pwd_allows_normal_user(monkeypatch, fake_request):
monkeypatch.setattr(user_service.user_repo, 'get_by_id', lambda uid: object())
monkeypatch.setattr(user_service.user_repo, 'update', lambda uid, fields: True)
result = user_service.reset_user_pwd(fake_request, types.SimpleNamespace(id=2))
assert _body(result)['ok'] is True
# ============ 内置角色写保护 ============
def test_delete_role_blocks_builtin():
result = asyncio.run(role_service.delete_role('1'))
assert _body(result)['ok'] is False
assert '内置' in _body(result)['msg']
def test_delete_role_blocks_mixed_builtin():
# 批量删除中混入内置角色 → 整体拒绝
result = asyncio.run(role_service.delete_role('2,5'))
assert _body(result)['ok'] is False
assert '内置' in _body(result)['msg']
def test_delete_role_allows_normal_role(monkeypatch, sqlite_db):
class _FakeRecord:
is_delete = 0
class _FakeQuery:
def all(self):
return [_FakeRecord()]
monkeypatch.setattr(role_repo, 'filter', lambda *a, **k: _FakeQuery())
async def fake_invalidate(rid):
pass
monkeypatch.setattr(role_service, 'invalidate_role_perms', fake_invalidate)
# sqlite_db 提供请求级 DB 会话(delete_role 会清理 user_role/role_menu 关联)
result = asyncio.run(role_service.delete_role('5'))
assert _body(result)['ok'] is True
@@ -0,0 +1,84 @@
"""部分更新防 NULL 覆盖:编辑时未传的可选字段不得把数据库旧值清空为 NULL。"""
import json
from modules.file_template.schemas import FileTemplateForm
from modules.file_template.service import file_template_service
from modules.city.schemas import CityForm
from modules.city.service import update_city
from modules.city.repository import city_repo
from modules.system.dept.schemas import DeptForm
from modules.system.dept.service import update_dept
from modules.system.dept.repository import dept_repo
from modules.article.schemas import ArticleForm
from modules.article.service import update_article
from modules.article.repository import article_repo
def _body(result):
return json.loads(result.body)
# ============ base 层 _build_update_fields ============
def test_build_update_fields_excludes_none(fake_request):
data = FileTemplateForm(id=1, name='模板', sort=5) # code/file_name/file_path/note 未传 → None
fields = file_template_service._build_update_fields(fake_request, data)
assert 'id' not in fields
assert 'name' in fields
assert 'sort' in fields
assert 'code' not in fields
assert 'filePath' not in fields
assert 'update_user' in fields
assert 'update_time' in fields
# ============ city:编辑顶级时 parent_id 不被清空 ============
def test_update_city_preserves_parent_id_when_omitted(monkeypatch, fake_request):
monkeypatch.setattr(city_repo, 'get_by_id', lambda uid: object())
captured = {}
monkeypatch.setattr(city_repo, 'update', lambda uid, fields: captured.update(fields) or True)
data = CityForm(
id=1, name='省', shortName='s', pinyin='p', cityCode='010',
areaCode='11', zipCode='100', level=1, lng='0', lat='0',
) # parentId 未传 → None
result = update_city(fake_request, data)
assert _body(result)['ok'] is True
assert 'parentId' not in captured
# ============ dept:编辑顶级时 parent_id 不被清空 ============
def test_update_dept_preserves_parent_id_when_omitted(monkeypatch, fake_request):
monkeypatch.setattr(dept_repo, 'exists_by_field', lambda *a, **k: False)
monkeypatch.setattr(dept_repo, 'get_by_id', lambda uid: object())
captured = {}
monkeypatch.setattr(dept_repo, 'update', lambda uid, fields: captured.update(fields) or True)
data = DeptForm(id=1, name='部门', code='D1', type=1, sort=1) # parent_id/note 未传 → None
result = update_dept(fake_request, data)
assert _body(result)['ok'] is True
assert 'parentId' not in captured
assert 'note' not in captured
# ============ article:未传的 category_id/intro/author 不被清空 ============
def test_update_article_preserves_optional_fields_when_omitted(monkeypatch, fake_request):
class _OldArticle:
cover = None
images = None
content = None
monkeypatch.setattr(article_repo, 'get_by_id', lambda uid: _OldArticle())
captured = {}
monkeypatch.setattr(article_repo, 'update', lambda uid, fields: captured.update(fields) or True)
data = ArticleForm(id=1, title='标题', status=0) # category_id/intro/author 未传 → None
result = update_article(fake_request, data)
assert _body(result)['ok'] is True
assert 'title' in captured
assert 'categoryId' not in captured
assert 'intro' not in captured
assert 'author' not in captured
+87
View File
@@ -0,0 +1,87 @@
"""上传安全:SVG/WebP 整体禁止上传(存储型 XSS 面 / 无可靠魔数)。"""
import asyncio
import json
from modules.upload import service as upload_service
from modules.upload.service import _save_upload, upload_file
class _FakeUpload:
"""模拟异步分块读取的 UploadFile。"""
def __init__(self, filename, data=b''):
self.filename = filename
self._data = data
async def read(self, size=65536):
data = self._data
self._data = b''
return data
def test_upload_file_rejects_svg(monkeypatch):
# SVG 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg')
result = asyncio.run(upload_file(_FakeUpload('logo.svg', b'<svg/>')))
body = json.loads(result.body)
assert body['ok'] is False
def test_save_upload_rejects_svg_even_if_config_allows():
# 兜底硬拒绝:即使配置或其他入口放行 .svg,落盘前一律拒绝
content = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
result = _save_upload(content, 'a.svg', '.svg')
body = json.loads(result.body)
assert body['ok'] is False
assert 'SVG' in body['msg']
def test_upload_file_rejects_webp(monkeypatch):
# WebP 被 BANNED_UPLOAD_EXTS 硬拒绝(即使配置放行)
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.webp')
result = asyncio.run(upload_file(_FakeUpload('a.webp', b'RIFF' + b'\x00' * 32)))
body = json.loads(result.body)
assert body['ok'] is False
def test_save_upload_rejects_webp_even_if_config_allows():
# 兜底硬拒绝:webp 魔数仅 RIFF 前缀(WAV/AVI 可冒充),落盘前一律拒绝
result = _save_upload(b'RIFF' + b'\x00' * 32, 'a.webp', '.webp')
body = json.loads(result.body)
assert body['ok'] is False
assert 'WebP' in body['msg']
def test_upload_file_uses_builtin_set_when_unconfigured(monkeypatch):
# 方案A:未配置时回落内置智能集合,白名单校验放行内置格式
from core import R
called = {}
def fake_save(content, original_name, ext):
called['ext'] = ext
return R.ok(msg="ok", data={})
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '')
monkeypatch.setattr(upload_service, '_save_upload', fake_save)
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
body = json.loads(result.body)
assert body['ok'] is True
assert called['ext'] == '.jpg'
def test_upload_file_fail_closed_when_config_outside_builtin(monkeypatch):
# 配置的扩展名全不在内置集合内 → 有效白名单为空 → 拒绝一切上传
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg,.webp,.exe')
result = asyncio.run(upload_file(_FakeUpload('a.jpg', b'\xff\xd8\xff')))
body = json.loads(result.body)
assert body['ok'] is False
assert '允许上传范围' in body['msg']
def test_upload_file_rejects_ext_outside_effective_set(monkeypatch):
# 配置收缩白名单后,内置集合中但不在配置内的扩展名被拒绝
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.jpg,.png')
result = asyncio.run(upload_file(_FakeUpload('a.txt', b'hello')))
body = json.loads(result.body)
assert body['ok'] is False
assert '文件格式' in body['msg']
View File
+27
View File
@@ -0,0 +1,27 @@
"""scripts/init_db.py 的 Oracle 自增 SQL 生成器单测(与 migrate_db 对齐)。
Oracle create_all 不生成 Integer 主键自增,init_db 会补 sequence + BEFORE INSERT 触发器;
这里验证生成的 DDL 字符串正确(与 scripts/migrate_db.py 的既有实现一致)。
"""
import sys
from pathlib import Path
# scripts/ 下无 __init__.py,把 scripts 目录加进 sys.path 以便直接 import
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import init_db # noqa: E402
def test_oracle_sequence_sql():
seq = init_db.oracle_sequence_sql("fastapi_user", 27)
assert seq == "CREATE SEQUENCE fastapi_user_id_seq START WITH 27 INCREMENT BY 1 NOCACHE"
def test_oracle_trigger_sql():
trg = init_db.oracle_trigger_sql("fastapi_user")
assert "BEFORE INSERT ON fastapi_user FOR EACH ROW" in trg
assert "WHEN (NEW.id IS NULL)" in trg
assert "SELECT fastapi_user_id_seq.NEXTVAL INTO :NEW.id FROM dual" in trg
+263
View File
@@ -0,0 +1,263 @@
"""
跨库数据泵(scripts/migrate_db.py)单元测试
==========================================
用 sqlite 文件同时充当「源库」和「目标库」,验证泵逻辑全链路:
读→分块→executemany→显式 id 保真→自增延续→Text/日期/BigInteger 保真→
软删/空时间原样→only-tables→dry-run→逐表 count 校验→建库→序列重置 SQL。
不依赖真实 MySQL/PostgreSQL(不挂 integration marker,pytest 默认即跑)。
"""
import os
import sys
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select, text
# scripts/ 下无 __init__.py,把 scripts 目录加进 sys.path 以便直接 import
_SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import migrate_db
def make_args(src_db: str, dst_db: str, **overrides) -> SimpleNamespace:
"""构造 sqlite→sqlite 迁移的 CLI 参数(等价 migrate_db.build_parser().parse_args)。"""
base = dict(
src_driver="sqlite", src_host="", src_port=None, src_db=src_db, src_user="", src_pass="",
dst_driver="sqlite", dst_host="", dst_port=None, dst_db=dst_db, dst_user="", dst_pass="",
drop_target_first=False, dry_run=False, chunk_size=1000, only_tables="",
skip_existing=False, skip_verify=False, single_transaction=False,
)
base.update(overrides)
return SimpleNamespace(**base)
@pytest.fixture(scope="module")
def loaded_metadata():
"""一次性加载全部模型填充 Base.metadata(与 migrate_db.run 同源)。"""
from core.app import create_app
from core.database import Base
create_app()
return Base
@pytest.fixture
def two_sqlite(tmp_path, loaded_metadata):
"""一对 sqlite 文件库:源库 + 目标库,均已按模型建好空结构。"""
src_path = tmp_path / "src.db"
dst_path = tmp_path / "dst.db"
src_engine = create_engine(f"sqlite:///{src_path}")
dst_engine = create_engine(f"sqlite:///{dst_path}")
loaded_metadata.metadata.create_all(src_engine)
loaded_metadata.metadata.create_all(dst_engine)
yield src_engine, dst_engine, src_path, dst_path
src_engine.dispose()
dst_engine.dispose()
def _table(loaded_metadata, name: str):
return loaded_metadata.metadata.tables["fastapi_" + name]
def test_make_url():
"""URL 形态与 core.config.database.build_database_url 逐字等价(特殊字符口令编码)。"""
assert migrate_db.make_url("mysql", "127.0.0.1", 3306, "db", "root", "p@ss") \
== "mysql+pymysql://root:p%40ss@127.0.0.1:3306/db?charset=utf8mb4"
assert migrate_db.make_url("postgresql", "127.0.0.1", 5432, "db", "root", "p@ss") \
== "postgresql+psycopg://root:p%40ss@127.0.0.1:5432/db"
assert migrate_db.make_url("mssql", "127.0.0.1", 1433, "db", "sa", "p@ss") \
== "mssql+pymssql://sa:p%40ss@127.0.0.1:1433/db?charset=utf8"
assert migrate_db.make_url("oracle", "127.0.0.1", 1521, "orcl", "hr", "p@ss") \
== "oracle+oracledb://hr:p%40ss@127.0.0.1:1521/?service_name=orcl"
assert migrate_db.make_url("sqlite", "x", None, "dev.db", "", "") == "sqlite:///./dev.db"
def test_pump_preserves_data_ids_and_autoincrement(two_sqlite, loaded_metadata):
"""泵后 id/父子引用/Text/BigInteger/软删/空时间全保真,且新插入自增延续。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [
{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0, "sort": 1},
{"id": 2, "name": "研发部", "code": "RD", "parent_id": 1, "sort": 2},
])
conn.execute(_table(loaded_metadata, "user").insert(), [
{"id": 1, "username": "admin", "password": "$2b$12$abcdefghijklmnop", "salt": "salt1",
"realname": "管理员", "nickname": "管理员", "avatar": "", "mobile": "13800000000",
"email": "admin@test.com", "province_code": "110000", "city_code": "110100",
"district_code": "110101", "street_code": "", "address": "北京市", "is_delete": 0},
{"id": 2, "username": "softdel", "password": None, "salt": None,
"realname": "软删", "nickname": "软删", "avatar": "",
"mobile": "13900000000", "email": "s@test.com", "province_code": "", "city_code": "",
"district_code": "", "street_code": "", "address": "", "is_delete": 1},
])
# base_model 的 create_time 带 Python 默认值,Core insert 显式 None 也会被默认覆盖;
# 用 UPDATE 强制置 NULL,验证泵能原样搬空时间列
conn.execute(text("UPDATE fastapi_user SET create_time = NULL WHERE id = 2"))
conn.execute(_table(loaded_metadata, "city").insert(), [
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
"area_code": "110000", "zip_code": "100000",
"parent_code": None, "parent_id": 0, "level": 1},
{"id": 2, "name": "东城区", "short_name": "东城", "city_code": "010",
"area_code": "110101", "zip_code": "100010",
"parent_code": "1101", "parent_id": 1, "level": 2},
])
conn.execute(_table(loaded_metadata, "article").insert(), [
{"id": 1, "title": "中文标题",
"content": "第一行\n第二行 with <b>html</b> 中文", "images": "a.jpg,b.jpg"},
])
conn.execute(_table(loaded_metadata, "job_log").insert(), [
{"id": 1, "job_name": "测试任务", "consume_time": 9223372036854775807}, # BIGINT 上限
])
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
# 逐表 count 相等(含空表)
for t in migrate_db.ordered_tables():
with src_engine.connect() as src, dst_engine.connect() as dst:
s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
assert s == d, t.name
with dst_engine.connect() as conn:
# 目标 id 与源逐行一致 + 子树 parent_id 有效
dept = [r for r in conn.execute(select(_table(loaded_metadata, "dept")).order_by(
_table(loaded_metadata, "dept").c.id))]
assert [(r.id, r.parent_id) for r in dept] == [(1, 0), (2, 1)]
# 软删行 + 空 create_time 原样;密码/salt 原样
users = [r for r in conn.execute(select(_table(loaded_metadata, "user")).order_by(
_table(loaded_metadata, "user").c.id))]
assert users[0].password == "$2b$12$abcdefghijklmnop"
assert users[1].is_delete == 1
assert users[1].create_time is None
# Text 逐字节相等(中文/换行/HTML)
content = conn.execute(select(_table(loaded_metadata, "article").c.content)).scalar_one()
assert content == "第一行\n第二行 with <b>html</b> 中文"
# BigInteger 保真
assert conn.execute(select(_table(loaded_metadata, "job_log").c.consume_time)).scalar_one() \
== 9223372036854775807
# 目标新插 dept 自增为 MAX(id)+1(sqlite rowid 自动跟随显式插 id)
with dst_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"name": "新部门", "code": "NEW", "parent_id": 0}])
with dst_engine.connect() as conn:
new_id = conn.execute(
select(_table(loaded_metadata, "dept").c.id).order_by(
_table(loaded_metadata, "dept").c.id.desc())).scalar()
assert new_id == 3
def test_chunking_roundtrip(two_sqlite, loaded_metadata):
"""chunk 循环边界:源 2500 行 city、chunk=1000,目标 count/ids 一致。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
city = _table(loaded_metadata, "city")
rows = [{"id": i, "name": f"区域{i}", "short_name": f"区域{i}", "city_code": "010",
"area_code": f"{i:06d}", "zip_code": "100000",
"parent_id": 0, "parent_code": None, "level": 1}
for i in range(1, 2501)]
with src_engine.begin() as conn:
conn.execute(city.insert(), rows)
assert migrate_db.run(make_args(str(src_path), str(dst_path), chunk_size=1000)) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 2500
ids = [r[0] for r in conn.execute(select(city.c.id).order_by(city.c.id))]
assert ids == list(range(1, 2501))
def test_only_tables_filter(two_sqlite, loaded_metadata):
"""--only-tables 只泵指定表(支持全名/短名),其余表保持空。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
conn.execute(_table(loaded_metadata, "city").insert(), [
{"id": 1, "name": "北京市", "short_name": "北京", "city_code": "010",
"area_code": "110000", "zip_code": "100000", "parent_id": 0}])
conn.execute(_table(loaded_metadata, "article").insert(), [{"id": 1, "title": "x"}])
assert migrate_db.run(make_args(str(src_path), str(dst_path),
only_tables="fastapi_dept,city")) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_city")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_article")).scalar() == 0
def test_dry_run_does_not_touch_target(two_sqlite, loaded_metadata):
"""--dry-run 只打印计划,目标库零写入。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
assert migrate_db.run(make_args(str(src_path), str(dst_path), dry_run=True)) == 0
with dst_engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM fastapi_dept")).scalar() == 0
def test_pg_reset_sql():
sql = migrate_db.pg_reset_sql("fastapi_user")
assert "setval" in sql
assert "pg_get_serial_sequence('fastapi_user', 'id')" in sql
assert 'FROM "fastapi_user"' in sql
# 空表用 setval(seq, 1, false)(next=1),不能 setval(seq, 0)(PG 报 NumericValueOutOfRange)
assert "COALESCE((SELECT MAX(id) FROM \"fastapi_user\"), 1)" in sql
assert "(SELECT MAX(id) FROM \"fastapi_user\") IS NOT NULL" in sql
def test_oracle_autoincrement_sql():
"""Oracle 自增:sequence 从 MAX+1 起,触发器 id 为 NULL 时取 NEXTVAL。"""
seq = migrate_db.oracle_sequence_sql("fastapi_user", 27)
assert seq == "CREATE SEQUENCE fastapi_user_id_seq START WITH 27 INCREMENT BY 1 NOCACHE"
trg = migrate_db.oracle_trigger_sql("fastapi_user")
assert "BEFORE INSERT ON fastapi_user FOR EACH ROW" in trg
assert "WHEN (NEW.id IS NULL)" in trg
assert "SELECT fastapi_user_id_seq.NEXTVAL INTO :NEW.id FROM dual" in trg
def test_mssql_reset_sql():
assert migrate_db.mssql_reset_sql("fastapi_user", 5) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 5)"
# 空表 max_id 为 None → RESEED 0,下一 id=1
assert migrate_db.mssql_reset_sql("fastapi_user", None) == "DBCC CHECKIDENT ('fastapi_user', RESEED, 0)"
def test_create_database_if_missing_sqlite(tmp_path):
"""sqlite 建库:父目录自动创建;drop_first 删除已存在文件。"""
db = tmp_path / "nested" / "new.db"
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=False)
eng = create_engine(migrate_db.make_url("sqlite", "", None, str(db), "", ""))
with eng.connect():
pass # 触发 sqlite 建文件
eng.dispose()
assert db.exists()
migrate_db.create_database_if_missing("sqlite", "", None, str(db), "", "", drop_first=True)
assert not db.exists()
def test_verify_counts_reports_mismatch(two_sqlite, loaded_metadata):
"""count 校验能报出不一致的表。"""
src_engine, dst_engine, src_path, dst_path = two_sqlite
with src_engine.begin() as conn:
conn.execute(_table(loaded_metadata, "dept").insert(), [{"id": 1, "name": "总部", "code": "HQ", "parent_id": 0}])
assert migrate_db.run(make_args(str(src_path), str(dst_path))) == 0
# 泵完后删一行制造不一致
with dst_engine.begin() as conn:
conn.execute(text("DELETE FROM fastapi_dept WHERE id = 1"))
with src_engine.connect() as src, dst_engine.connect() as dst:
failed = migrate_db.verify_counts(src, dst, migrate_db.ordered_tables())
assert any(name == "fastapi_dept" for name, _, _ in failed)
View File
+86
View File
@@ -0,0 +1,86 @@
"""utils/captcha.py 验证码校验:原子消费(get→del→compare)杜绝重放与无限试错。
fakeredis 不支持 Lua eval,这里用 Python 复刻 Lua 语义(原子 get→del→compare,
无论对错都消费验证码)作为 fake,验证业务层行为;另断言生产代码确实调用
_CAPTCHA_VERIFY_LUA 脚本。
"""
import pytest
import fakeredis.aioredis
@pytest.fixture
def _captcha_redis(monkeypatch):
from core import redis_client as rc
redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
async def fake_eval(script, numkeys, key, user_input):
# 复刻 _CAPTCHA_VERIFY_LUA 语义:原子 get→del→compare,无论对错都消费
stored = await redis.get(key)
if not stored:
return 0
await redis.delete(key)
if str(stored).upper().strip() == str(user_input).upper().strip():
return 1
return 0
monkeypatch.setattr(redis, 'eval', fake_eval)
monkeypatch.setattr(rc, 'get_redis', lambda: redis)
return redis
@pytest.mark.asyncio
async def test_verify_captcha_correct_then_consumed(_captcha_redis):
from utils.captcha import verify_captcha
await _captcha_redis.set('k1', 'Ab3X')
# 大小写不敏感比较通过
assert await verify_captcha('k1', 'ab3x') is True
# 已消费:同一验证码无法再次使用(防重放)
assert await verify_captcha('k1', 'Ab3X') is False
@pytest.mark.asyncio
async def test_verify_captcha_wrong_input_burns_captcha(_captcha_redis):
from utils.captcha import verify_captcha
await _captcha_redis.set('k2', 'Ab3X')
# 错误输入 → False,且验证码被消费(失败即失效,防无限试错)
assert await verify_captcha('k2', 'wrong') is False
# 随后即使用正确输入也无法通过
assert await verify_captcha('k2', 'Ab3X') is False
assert await _captcha_redis.get('k2') is None
@pytest.mark.asyncio
async def test_verify_captcha_missing_key(_captcha_redis):
from utils.captcha import verify_captcha
assert await verify_captcha('missing', 'any') is False
@pytest.mark.asyncio
async def test_verify_captcha_empty_input(_captcha_redis):
from utils.captcha import verify_captcha
assert await verify_captcha('k', '') is False
assert await verify_captcha('', 'x') is False
@pytest.mark.asyncio
async def test_verify_captcha_uses_atomic_lua_script(_captcha_redis, monkeypatch):
from utils.captcha import verify_captcha, _CAPTCHA_VERIFY_LUA
await _captcha_redis.set('k1', 'Ab3X')
calls = []
original = _captcha_redis.eval
async def spy_eval(script, numkeys, key, user_input):
calls.append((script, numkeys))
return await original(script, numkeys, key, user_input)
monkeypatch.setattr(_captcha_redis, 'eval', spy_eval)
assert await verify_captcha('k1', 'Ab3X') is True
# 生产代码调用原子 Lua 脚本(numkeys=1)
assert calls and calls[0] == (_CAPTCHA_VERIFY_LUA, 1)
+51
View File
@@ -0,0 +1,51 @@
"""utils/file.py 魔数校验:读取异常必须 fail-closed 拒绝上传,不得放行伪装文件。"""
import io
from utils.file import verify_file_magic
class _BrokenStream:
"""模拟文件流读取失败(seek/read 抛异常)。"""
def read(self, n):
raise OSError('模拟读取失败')
def seek(self, n):
raise OSError('模拟 seek 失败')
def test_verify_file_magic_fail_closed_on_read_error():
is_valid, msg = verify_file_magic(_BrokenStream(), '.jpg')
assert is_valid is False
assert '校验异常' in msg
def test_verify_file_magic_accepts_valid_magic():
png_header = b'\x89PNG\r\n\x1a\n' + b'filler' * 10
is_valid, msg = verify_file_magic(io.BytesIO(png_header), '.png')
assert is_valid is True
assert msg == ''
def test_verify_file_magic_rejects_mismatched_content():
# 内容与扩展名不符(JPEG 头冒充 .png)→ 拒绝
jpeg_bytes = b'\xff\xd8\xff\xe0' + b'filler' * 10
is_valid, msg = verify_file_magic(io.BytesIO(jpeg_bytes), '.png')
assert is_valid is False
assert '不匹配' in msg
def test_verify_file_magic_txt_accepts_text_content():
# 文本无固定二进制签名,可读文本(含空文件)放行
for content in (b'hello world', b'', '你好,世界'.encode('utf-8')):
is_valid, msg = verify_file_magic(io.BytesIO(content), '.txt')
assert is_valid is True, content
assert msg == ''
def test_verify_file_magic_txt_rejects_binary_content():
# 二进制内容伪装成 .txt(含 NUL 字节)→ 拒绝,防伪装文件绕过
binary = b'\x00\x00\x00\xff' + b'filler' * 10
is_valid, msg = verify_file_magic(io.BytesIO(binary), '.txt')
assert is_valid is False
assert '不匹配' in msg
+39
View File
@@ -0,0 +1,39 @@
"""utils/ip2region.py:畸形 IP 不得抛异常中断日志(内部降级返回"未知")。
回归场景:IP 形如 172.abc 时,int(parts[1]) 曾抛 ValueError 冒泡到
record_login_log / record_operation_log,使日志整段丢失。
"""
from utils.ip2region import _is_private_ip, get_ip_location, get_ip_region
def test_is_private_ip_handles_malformed_ip():
# 畸形 IP 不再抛 ValueError,按非内网处理(交查询侧兜底为"未知")
assert _is_private_ip('172.abc') is False
assert _is_private_ip('172.') is False
assert _is_private_ip('172.a.b') is False
def test_is_private_ip_private_ranges():
assert _is_private_ip('172.16.5.5') is True
assert _is_private_ip('172.31.5.5') is True
assert _is_private_ip('172.32.5.5') is False # 不在 172.16/12 私有段
assert _is_private_ip('10.1.2.3') is True
assert _is_private_ip('192.168.1.1') is True
assert _is_private_ip('8.8.8.8') is False
def test_get_ip_location_malformed_ip_degrades_to_unknown():
# 畸形 IP:不抛异常,降级返回"未知"(xdb 未加载时非内网 IP 也返回"未知")
assert get_ip_location('172.abc') == '未知'
def test_get_ip_region_malformed_ip_returns_unknown_dict():
region = get_ip_region('172.abc')
assert region['country'] == '未知'
assert set(region) == {'country', 'province', 'city', 'isp', 'country_code'}
def test_get_ip_location_private_ip_still_works():
# 内网 IP 无需 xdb,仍正常返回"内网IP"
assert get_ip_location('10.0.0.1') == '内网IP'
+70
View File
@@ -0,0 +1,70 @@
"""utils/ip.py 可信代理白名单:仅直连对端命中白名单才信任 X-Forwarded-For。"""
import types
from utils import ip as ip_mod
from utils.ip import get_client_ip
def _make_request(client_host, xff=None):
req = types.SimpleNamespace()
req.client = types.SimpleNamespace(host=client_host)
req.headers = {'X-Forwarded-For': xff} if xff else {}
return req
def test_proxy_count_zero_ignores_xff(monkeypatch):
# TRUSTED_PROXY_COUNT=0:即使带 XFF 头也不信任,直接返回 TCP 层 IP
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_proxy_count_positive_but_no_whitelist_is_fail_closed(monkeypatch):
# 配了 TRUSTED_PROXY_COUNT 但未配 TRUSTED_PROXY_IPS → 不信任 XFF(fail-closed)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset())
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_not_in_whitelist_rejects_xff(monkeypatch):
# 直连对端不是可信代理(应用被直接暴露,如 Docker bridge 映射端口)
# → 客户端伪造的 XFF 被忽略,返回真实对端 IP
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_in_whitelist_trusts_xff(monkeypatch):
# 单层代理:XFF 仅含客户端 IP(由可信代理追加),跳过最右侧 1 个代理条目即客户端
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4'
def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch):
# 客户端伪造条目只会出现在 XFF 左侧(由可信代理追加的在其右侧),伪造 IP 被忽略
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4'
def test_peer_in_whitelist_fewer_entries_than_proxies(monkeypatch):
# 代理条目少于层数(部分代理未追加 XFF)→ 回退到最右侧条目,避免拿到代理 IP
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4')) == '1.2.3.4'
def test_peer_in_whitelist_multiple_proxy_layers(monkeypatch):
# 两层代理:XFF=[客户端, 外层代理],最内层可信代理是直连对端(不在 XFF 内),
# 跳过最右侧 2 个代理条目取真实客户端
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 2)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.2'}))
assert get_client_ip(_make_request('10.0.0.2', xff='1.2.3.4, 10.0.0.1')) == '1.2.3.4'
def test_no_client_falls_back_to_localhost(monkeypatch):
req = types.SimpleNamespace(client=None, headers={})
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(req) == '127.0.0.1'